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:
cproudlock
2026-09-19 11:24:40 -04:00
parent cb3975049c
commit 534f179ee6
675 changed files with 115759 additions and 60239 deletions

View File

@@ -21,12 +21,14 @@ from .reports import reports_bp
from .collector import collector_bp
from .settings import settings_bp
from .auditlogs import auditlogs_bp
from .employees import employees_bp
from .users import users_bp
from .customfields import customfields_bp
from .setup import setup_bp
from .pluginui import pluginui_bp
from .apitokens import apitokens_bp
from .docs import docs_bp
from .shopdbbackups import shopdbbackups_bp
__all__ = [
'auth_bp',
@@ -50,10 +52,12 @@ __all__ = [
'collector_bp',
'settings_bp',
'auditlogs_bp',
'employees_bp',
'users_bp',
'customfields_bp',
'setup_bp',
'pluginui_bp',
'apitokens_bp',
'docs_bp',
'shopdbbackups_bp',
]

View File

@@ -49,9 +49,9 @@ def _validate_resourcescopes(resourcescopes):
"""Validate a resourcescopes payload. Return (list_or_none, error_response).
resourcescopes is a DIFFERENT axis from scopes: it pins the token to a set
of resource names (today geenforce manifest scope names, e.g.
gea-shopfloor-display), not permission names, so there is no catalog to
check against - a plugin owns the meaning. None/absent = unrestricted.
of resource names a plugin defines, not permission names, so there is no
catalog to check against - a plugin owns the meaning. None/absent =
unrestricted.
"""
if resourcescopes is None:
return None, None

View File

@@ -4,6 +4,7 @@ import glob
import hashlib
import re
import os
import shutil
from flask import Blueprint, request, current_app
from flask_jwt_extended import jwt_required
@@ -36,11 +37,147 @@ APP_PACKAGE_EXTENSIONS = {'.exe', '.msi', '.msp', '.zip', '.7z', '.cab', '.iso',
'.appx', '.msix', '.ps1', '.bat', '.txt', '.pdf'}
APP_PACKAGE_URL_PREFIX = '/api/applications/package/'
def _requires_policy_ack(app, data):
"""True when this save would newly generate an install-enforcing policy.
Ticking "Required on all PCs" does not just flag a report row: it creates a
policy that installs this application on every matched endpoint. That is a
real change to real machines, so the caller has to acknowledge it
explicitly. Enforced here rather than only in the form because the checkbox
is reachable through the API too, and a confirmation dialog that can be
skipped by calling the endpoint directly is not a safeguard.
Only NEWLY ticking it needs acknowledgement. Unticking, or saving an app
that was already required, does not.
"""
if not data.get('isrequired'):
return False
if app is not None and app.isrequired:
return False
return not data.get('acknowledgepolicy')
POLICY_ACK_MESSAGE = (
'Marking this application as required on all PCs creates a configuration '
'policy that installs it on every non-server endpoint it matches, once that '
'policy is added to an active business policy. It is not added to one '
'automatically. Confirm to create the policy.'
)
# Returned alongside policyresult='created'. The generated policy is not
# attached to any BusinessPolicy, and the resolver only applies policies reached
# through one, so saying "this will install everywhere" would be untrue.
POLICY_CREATED_NOTE = (
'A configuration policy was created for this application, but it is not '
'attached to any business policy and installs nothing until an admin adds '
'it to one.'
)
# Deleting an application is irreversible and takes its versions and uploaded
# installer with it, so the caller has to type this rather than click once.
# Checked server-side as well as in the dialog: the endpoint is reachable
# directly, and a confirmation that only exists in the UI is not a safeguard.
DELETE_CONFIRM_TOKEN = 'OK'
DELETE_CONFIRM_MESSAGE = (
'Deleting an application permanently removes it, its versions, and its '
'uploaded installer. This cannot be undone. Type OK to confirm.'
)
def _delete_confirmed(data):
"""True when the caller typed the confirmation token exactly.
Compared case-sensitively after stripping surrounding whitespace. Accepting
"ok" would defeat the point of asking for deliberate input.
"""
if not isinstance(data, dict):
return False
return str(data.get('confirm', '')).strip() == DELETE_CONFIRM_TOKEN
def _validate_monitorable(data, existing=None):
"""Return an error message if monitorable is set without its two fields.
A process monitor needs an image name to watch for and a path to launch when
it is missing; without both, ticking the box would create a monitor that
cannot do anything. Enforced here rather than as a DB constraint so the
columns can stay nullable for rows that predate the feature.
`existing` lets a PATCH-style update validate the MERGED result: a payload
that only toggles the checkbox is still checked against values already
stored on the row.
"""
def merged(field):
if field in data:
return (data.get(field) or '').strip()
return ((getattr(existing, field, None) or '').strip()
if existing is not None else '')
if 'ismonitorable' in data:
monitorable = data.get('ismonitorable')
else:
monitorable = getattr(existing, 'ismonitorable', False)
if not monitorable:
return None
if not merged('monitorlaunchpath'):
return 'A monitorable application needs a launch path'
if not merged('monitorprocessname'):
return 'A monitorable application needs a process name'
return None
def _policy_payload(payload, policyresult):
"""Attach the policy sync outcome (and the not-attached note) to a response."""
if policyresult in ('created', 'removed', 'kept'):
payload['policyresult'] = policyresult
if policyresult == 'created':
payload['policynote'] = POLICY_CREATED_NOTE
return payload
def _deactivate_policies_for_app(app_id):
"""Deactivate remaining policies that reference a deleted appid, if available."""
try:
from plugins.computers.services.apppolicy import deactivate_policies_for_app
except ImportError:
return []
return deactivate_policies_for_app(app_id)
def _sync_required_app_policy(app):
"""Sync the auto-generated "required on all PCs" policy, if available.
The policy engine lives in the computers PLUGIN, which may be absent or
disabled, so this is a guarded import: core must not hard-depend on a
plugin. When the plugin is not installed the checkbox keeps its original
compliance-report meaning and nothing else happens.
"""
try:
from plugins.computers.services.apppolicy import sync_required_app_policy
except ImportError:
return None
try:
return sync_required_app_policy(app)
except Exception:
# A policy-generation failure must not lose the user's application edit.
current_app.logger.exception(
'Failed to sync the required-app policy for appid=%s', app.appid)
return None
# A real installer runs to hundreds of megabytes; an ISO runs to gigabytes and
# does not belong in an instance directory. 500MB is the line: enough for the
# msi/exe this is for, small enough that the disk cannot vanish behind it.
MAX_PACKAGE_BYTES = 500 * 1024 * 1024
# How much of an installer to read when looking for its version resource. The
# PE resource directory is usually within the first few MB; reading the whole
# file would mean holding a 500MB upload in memory to learn a version string.
PE_VERSION_SCAN_BYTES = 32 * 1024 * 1024
def _appimage_dir():
return os.path.join(current_app.instance_path, 'applicationimages')
@@ -64,6 +201,46 @@ def _sha256_of(stream):
return digest.hexdigest()
def _detect_installer_version(stream):
"""Read the FileVersion out of an uploaded PE installer, or None.
Deliberately reuses the endpoints plugin's PE reader rather than parsing the
format again here. A version detected at upload has to match what the agent
reports from the installed machine, and two separate implementations of the
same structure walk would drift apart and make an endpoint look
out-of-compliance for no real reason.
FileVersion only, never ProductVersion: ProductVersion may carry semver
metadata (1.5.0+abc123) which is not a version number we can compare or use
in a path. Returns None for MSI and archives, which are not PE files at all
and simply have no version to read.
"""
try:
from plugins.endpoints.api.agentstore import read_pe_file_version
except ImportError:
return None
position = stream.tell()
try:
# The version resource lives in the PE header near the start, but the
# resource directory it points into can sit much deeper. Read a bounded
# prefix so a 500MB installer is not pulled into memory whole.
data = stream.read(PE_VERSION_SCAN_BYTES)
finally:
stream.seek(position)
# The parser documents itself as never raising, but it walks offsets read
# out of an attacker-supplied file. A version is a convenience; it must not
# cost the operator the upload of a 500MB installer if that guarantee ever
# slips.
try:
return read_pe_file_version(data)
except Exception:
current_app.logger.warning('Version detection failed for an installer',
exc_info=True)
return None
def _replace_upload(directory, stem, ext, upload):
"""Save one file per application, replacing any prior extension.
@@ -212,6 +389,13 @@ def create_application():
http_code=409
)
monitor_error = _validate_monitorable(data)
if monitor_error:
return error_response(ErrorCodes.VALIDATION_ERROR, monitor_error)
if _requires_policy_ack(None, data):
return error_response(ErrorCodes.VALIDATION_ERROR, POLICY_ACK_MESSAGE)
app = Application(
appname=data['appname'],
appdescription=data.get('appdescription'),
@@ -225,6 +409,9 @@ def create_application():
isprinter=data.get('isprinter', False),
islicenced=data.get('islicenced', False),
isrequired=data.get('isrequired', False),
ismonitorable=data.get('ismonitorable', False),
monitorlaunchpath=data.get('monitorlaunchpath'),
monitorprocessname=data.get('monitorprocessname'),
image=data.get('image')
)
@@ -234,9 +421,13 @@ def create_application():
AuditLog.log('created', 'Application', entityid=app.appid, entityname=app.appname)
policyresult = _sync_required_app_policy(app)
db.session.commit()
return success_response(app.to_dict(), message='Application created', http_code=201)
payload = _policy_payload(app.to_dict(), policyresult)
return success_response(payload, message='Application created', http_code=201)
@applications_bp.route('/<int:app_id>', methods=['PUT'])
@@ -260,12 +451,34 @@ def update_application(app_id: int):
http_code=409
)
monitor_error = _validate_monitorable(data, existing=app)
if monitor_error:
return error_response(ErrorCodes.VALIDATION_ERROR, monitor_error)
if _requires_policy_ack(app, data):
return error_response(ErrorCodes.VALIDATION_ERROR, POLICY_ACK_MESSAGE)
fields = [
'appname', 'appdescription', 'supportteamid', 'isinstallable',
'applicationnotes', 'installpath', 'applicationlink', 'documentationpath',
'ishidden', 'isprinter', 'islicenced', 'isrequired', 'image', 'isactive'
'ishidden', 'isprinter', 'islicenced', 'isrequired', 'image', 'isactive',
'ismonitorable', 'monitorlaunchpath', 'monitorprocessname'
]
# Only touch policy when the checkbox itself moved. Syncing on every save
# would let an unrelated edit resurrect a policy an admin had deleted.
#
# Unticking is synced BEFORE any field is applied, so a PUT that renames and
# unticks in one go withdraws the policy that belongs to the app as it was,
# not one looked up under the new name.
policyresult = None
unticking = ('isrequired' in data and bool(app.isrequired)
and not data['isrequired'])
if unticking:
app.isrequired = False
policyresult = _sync_required_app_policy(app)
app.isrequired = True
changes = {}
for key in fields:
if key in data:
@@ -279,27 +492,210 @@ def update_application(app_id: int):
AuditLog.log('updated', 'Application', entityid=app.appid,
entityname=app.appname, changes=changes)
if 'isrequired' in changes and not unticking:
policyresult = _sync_required_app_policy(app)
elif 'appname' in changes and app.isrequired:
# A rename of a required app keeps its generated policy's name current.
_sync_required_app_policy(app)
apply_import_timestamps(app, data)
db.session.commit()
return success_response(app.to_dict(), message='Application updated')
payload = _policy_payload(app.to_dict(), policyresult)
return success_response(payload, message='Application updated')
def _remove_package_files(app_id: int):
"""Delete the stored installer(s) for an application, leaving the image.
Split out from _remove_upload_files because revert-to-record removes the
executables but keeps the icon: the record stays in the catalogue and a
record with no image is a worse record. Delete calls both.
Best-effort by design: a missing or unreadable file must not abort the
operation and strand the row. Returns the filenames actually removed so the
audit entry records what went.
"""
removed = []
packagedir = os.path.join(_apppackage_dir(), str(app_id))
for path in glob.glob(os.path.join(packagedir, '*')):
removed.append(os.path.basename(path))
if os.path.isdir(packagedir):
shutil.rmtree(packagedir, ignore_errors=True)
if os.path.isdir(packagedir):
current_app.logger.warning(
'Could not remove %s for appid=%s', packagedir, app_id)
return removed
def _remove_upload_files(app_id: int):
"""Delete the stored installer and image for an application.
Used by delete, which takes everything. Revert calls
_remove_package_files() instead so the image survives.
"""
removed = []
# The image is still one flat file per application; the installer now lives
# in a per-application directory so it can keep its vendor filename.
pattern = os.path.join(_appimage_dir(),
secure_filename(f'application-{app_id}') + '.*')
for path in glob.glob(pattern):
try:
os.remove(path)
removed.append(os.path.basename(path))
except OSError:
current_app.logger.warning(
'Could not remove %s while deleting appid=%s', path, app_id)
return removed + _remove_package_files(app_id)
def _clear_application_references(app_id: int):
"""Detach rows that point at this application before it is deleted.
The FKs differ in intent, so they are not treated the same:
- appversions.appid is NOT NULL and the versions are meaningless without
the application, so they go.
- computerinstalledapps.appid is NOT NULL; these are inventory links, so
the link goes while the computer stays.
- knowledgebase.appid is nullable and the article is somebody's writing,
so it is unlinked rather than deleted.
"""
AppVersion.query.filter_by(appid=app_id).delete(synchronize_session=False)
models = _computer_models()
if models is not None:
_, ComputerInstalledApp = models
ComputerInstalledApp.query.filter_by(appid=app_id).delete(
synchronize_session=False)
try:
from plugins.knowledgebase.models import KnowledgeBase
except ImportError:
return
KnowledgeBase.query.filter_by(appid=app_id).update(
{'appid': None}, synchronize_session=False)
@applications_bp.route('/<int:app_id>', methods=['DELETE'])
@require_permission('applications.delete')
def delete_application(app_id: int):
"""Delete (deactivate) an application."""
"""Permanently delete an application, its versions, and its uploads.
This is a hard delete. The previous soft delete only set isactive=False,
which left the name reserved forever: creating an application with the same
name failed with "already exists" against a row no screen would show.
"""
app = db.session.get(Application, app_id)
if not app:
return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404)
app.isactive = False
if not _delete_confirmed(request.get_json(silent=True)):
return error_response(ErrorCodes.VALIDATION_ERROR, DELETE_CONFIRM_MESSAGE)
AuditLog.log('deleted', 'Application', entityid=app.appid, entityname=app.appname)
# Withdraw the generated policy first. A policy that outlived its
# application would keep installing something that no longer exists.
appname = app.appname
app.isrequired = False
policyresult = _sync_required_app_policy(app)
# 'kept' means a customised generated policy survived the sync, and other
# hand-authored policies may name this appid too. Every one of them would
# keep trying to install an application that no longer exists, so they are
# deactivated (not deleted - they are someone's work) and reported.
deactivated = _deactivate_policies_for_app(app_id)
_clear_application_references(app_id)
removed = _remove_upload_files(app_id)
db.session.delete(app)
# The audit row is the only record left that this application existed, so
# it names the files that went with it.
auditchanges = {}
if removed:
auditchanges['removedfiles'] = removed
if deactivated:
auditchanges['deactivatedpolicies'] = deactivated
AuditLog.log('deleted', 'Application', entityid=app_id, entityname=appname,
changes=auditchanges or None)
db.session.commit()
return success_response(message='Application deleted')
payload = {}
if policyresult in ('removed', 'kept'):
payload['policyresult'] = policyresult
if deactivated:
payload['deactivatedpolicies'] = deactivated
message = 'Application deleted'
if deactivated:
message += ('; deactivated {} policy(ies) that referenced it: {}'
.format(len(deactivated), ', '.join(deactivated)))
return success_response(payload or None, message=message)
REVERT_CONFIRM_MESSAGE = (
'Remove all policy associations and uploaded executables? This action '
"cannot be reversed. Type OK to proceed"
)
@applications_bp.route('/<int:app_id>/revert', methods=['POST'])
@require_permission('applications.edit')
def revert_application_to_record(app_id: int):
"""Strip an application back to a catalogue record.
The middle ground between editing and deleting. Deleting loses the name,
the description and the knowledgebase links, which is too much when the
intent is only to stop deploying something and re-upload a clean installer.
This withdraws the generated policy, drops the version rows and removes the
uploaded files, while the application itself stays in the catalogue.
Inventory links are deliberately kept: computerinstalledapps records that a
machine really does have this software on it, and that stays true whether
or not we still ship an installer for it. Deleting those would make the
fleet look clean when it is not.
Gated on applications.edit rather than applications.delete: the record
survives, and a user trusted to change an application is trusted to stop
deploying it.
"""
app = db.session.get(Application, app_id)
if not app:
return error_response(ErrorCodes.NOT_FOUND, 'Application not found',
http_code=404)
if not _delete_confirmed(request.get_json(silent=True)):
return error_response(ErrorCodes.VALIDATION_ERROR, REVERT_CONFIRM_MESSAGE)
# Withdraw the generated policy first, for the same reason delete does: a
# policy left behind would keep pushing an installer we just removed.
app.isrequired = False
_sync_required_app_policy(app)
AppVersion.query.filter_by(appid=app_id).delete(synchronize_session=False)
# Executables only. The image is not an executable and the warning does not
# offer to remove it - the record survives this operation, and stripping its
# icon would degrade the thing we are explicitly keeping.
removed = _remove_package_files(app_id)
# Only clear an installpath we own. A UNC path typed by an operator points
# at their share, not our upload directory, and is not ours to wipe.
if (app.installpath or '').startswith(APP_PACKAGE_URL_PREFIX):
app.installpath = None
app.installersha256 = None
AuditLog.log('updated', 'Application', entityid=app_id,
entityname=app.appname,
changes={'revertedtorecord': {'new': True},
'removedfiles': removed})
db.session.commit()
return success_response(app.to_dict(), message='Application reverted to record')
# ---- Versions ----
@@ -613,14 +1009,43 @@ def delete_application_image(app_id: int):
return success_response(app.to_dict(), message='Application image removed')
def _store_package(app_id: int, upload):
"""Save an installer under its own vendor filename, one per application.
Keeping the shipped name matters at import: "DeviceLoggerSetup.msi" is
recognisable as the thing already in the catalogue, whereas the old
application-<id>.msi told the operator nothing and made a re-import look
like new software, which is how duplicate applications and versions got
created in the first place.
Files are namespaced by application id so two applications shipping a
generic "setup.exe" cannot overwrite each other. Any previous installer for
this application is removed first, so replacing one does not accumulate.
Returns (storedname, originalname).
"""
directory = os.path.join(_apppackage_dir(), str(app_id))
if os.path.isdir(directory):
shutil.rmtree(directory, ignore_errors=True)
os.makedirs(directory, exist_ok=True)
original = os.path.basename(upload.filename or '')
# secure_filename can reduce a name to nothing (all-unicode, all-dots), and
# an empty name would save as a bare directory entry.
stored = secure_filename(original) or 'installer{}'.format(
os.path.splitext(original)[1].lower())
upload.save(os.path.join(directory, stored))
return stored, original
@applications_bp.route('/<int:app_id>/package', methods=['POST'])
@require_permission('applications.edit')
def upload_application_package(app_id: int):
"""Upload (or replace) the installer for an application.
multipart/form-data: file=<installer>. The original filename is kept in
installpath so the download arrives named the way the vendor shipped it,
and the stored copy is application-<id><ext>.
multipart/form-data: file=<installer>. The installer is stored under the
filename the vendor shipped, inside a per-application directory.
"""
app = db.session.get(Application, app_id)
if not app:
@@ -650,8 +1075,11 @@ def upload_application_package(app_id: int):
'on the share and link to them with Install Path.'.format(
size / 1048576, MAX_PACKAGE_BYTES / 1048576))
filename = _replace_upload(_apppackage_dir(), f'application-{app_id}', ext, upload)
app.installpath = '{}{}'.format(APP_PACKAGE_URL_PREFIX, filename)
# Read the version before saving: the stream is consumed by save().
detectedversion = _detect_installer_version(upload.stream)
filename, _original = _store_package(app_id, upload)
app.installpath = '{}{}/{}'.format(APP_PACKAGE_URL_PREFIX, app_id, filename)
# The endpoint installer push pins bytes by digest; compute it once here so
# the server, not the agent, decides what the correct payload is. Stamp it
@@ -682,6 +1110,10 @@ def upload_application_package(app_id: int):
return success_response(
{**app.to_dict(), 'uploadedfilename': upload.filename, 'bytes': size,
'sha256': sha256,
# None means "no version resource" - the form must ask the operator
# rather than silently saving an installer with no version, which is
# how duplicate version rows appeared.
'detectedversion': detectedversion,
'appversionid': stamped_version.appversionid if stamped_version else None},
message='Installer uploaded')
@@ -698,6 +1130,10 @@ def serve_application_package(filename):
Gated on applications.download, not applications.view: a guest may be
granted the metadata permission, and "an open URL would publish it" is just
as true of a URL any anonymous visitor can reach.
filename is "<appid>/<vendorname>" now that installers keep the name they
shipped under. send_from_directory refuses to escape the base directory, so
the appid segment cannot be walked out of with "..".
"""
return send_upload(_apppackage_dir(), filename, as_attachment=True)
@@ -712,12 +1148,12 @@ def delete_application_package(app_id: int):
f'Application with ID {app_id} not found',
http_code=404)
for old in glob.glob(os.path.join(_apppackage_dir(),
secure_filename(f'application-{app_id}') + '.*')):
os.remove(old)
shutil.rmtree(os.path.join(_apppackage_dir(), str(app_id)),
ignore_errors=True)
# Only clear the path if it pointed at the upload; a site's own share path
# was typed by a person and is not ours to wipe.
if (app.installpath or '').startswith(APP_PACKAGE_URL_PREFIX):
app.installpath = None
app.installersha256 = None
db.session.commit()
return success_response(app.to_dict(), message='Installer removed')

View File

@@ -997,14 +997,6 @@ def get_assets_map():
)
except (ImportError, AttributeError):
pass
try:
from plugins.measuringtools.models import MeasuringTool
eager_options.append(
subqueryload(Asset.measuringtool)
.joinedload(MeasuringTool.measuringtooltype)
)
except (ImportError, AttributeError):
pass
query = Asset.query.options(*eager_options).filter(
Asset.isactive == True,
@@ -1041,7 +1033,7 @@ def get_assets_map():
# Filter by subtype (depends on asset type) - case-insensitive matching
if (subtype_id := intarg('subtype')) is not None:
# Normalize the underscore DB form (measuring_tool, network_device) to
# Normalize the underscore DB form (network_device) to
# the space form the branches below compare against.
asset_type_lower = (
selected_assettype.lower().replace('_', ' ') if selected_assettype else '')
@@ -1077,15 +1069,6 @@ def get_assets_map():
)
except ImportError:
pass
elif asset_type_lower == 'measuring tool':
try:
from plugins.measuringtools.models import MeasuringTool
query = query.join(
MeasuringTool, MeasuringTool.assetid == Asset.assetid).filter(
MeasuringTool.measuringtooltypeid == subtype_id
)
except ImportError:
pass
# Filter by business unit
if (bu_id := intarg('businessunitid')) is not None:
@@ -1224,14 +1207,6 @@ def get_assets_map():
except ImportError:
subtypes['Printer'] = []
try:
from plugins.measuringtools.models import MeasuringToolType
measuringtool_types = MeasuringToolType.query.filter(
MeasuringToolType.isactive == True).order_by(MeasuringToolType.name).all()
subtypes['Measuring Tool'] = [{'id': mt.measuringtooltypeid, 'name': mt.name, 'color': mt.color} for mt in measuringtool_types]
except ImportError:
subtypes['Measuring Tool'] = []
return success_response({
'assets': data,
'total': len(data),

View File

@@ -13,27 +13,16 @@ auditlogs_bp = Blueprint('auditlogs', __name__)
def _resolve_full_names(ssos):
"""Best-effort SSO -> full name map for audit rows. Mode-aware, guarded,
degrades to {} so the list still renders if the directory is unreachable."""
"""Best-effort SSO -> full name map for audit rows. Guarded, degrades to {}
so the list still renders if the directory is unreachable."""
wanted = {s for s in ssos if s and str(s).isdigit()}
if not wanted:
return {}
names = {}
# Self-hosted directory first (no external dependency).
try:
from plugins.employees.models import DirectoryEmployee
rows = DirectoryEmployee.query.filter(
DirectoryEmployee.sso.in_(wanted)).all()
for emp in rows:
full = f'{(emp.firstname or "").strip()} {(emp.lastname or "").strip()}'.strip()
if full:
names[str(emp.sso)] = full
except Exception:
pass
missing = wanted - set(names)
if not missing:
return names
# External HR directory for anything still unresolved.
missing = set(wanted)
# External HR directory. Best effort: a site with EMPLOYEE_DB_* unset, or a
# directory that is down, gets bare SSOs rather than an error page over a
# log it can still read.
try:
from shopdb.utils.employee_db import employee_connection
conn = employee_connection()

View File

@@ -23,6 +23,48 @@ collector_bp = Blueprint('collector', __name__)
# identity - no require_permission on these routes).
COLLECTOR_SCOPE = 'collector.ingest'
# Per-PC agent credential (contract 0.33.0). Accepted only by collector schemas
# declaring acceptsagentcredential.
AGENT_CREDENTIAL_HEADER = 'X-Endpoint-Credential'
# How a collector request authorized, recorded on g so a route can tell a
# managed service token from the shared env key after the check has passed.
AUTH_MANAGED_TOKEN = 'token'
AUTH_SHARED_KEY = 'sharedkey'
# The four legacy routes below (/pc, /apps, /heartbeat, /bulk) predate both the
# generic collector contract and per-PC credentials. They take a hostname from
# the body and rewrite that PC's logged-in user, boot time, serial number and
# installed-app inventory, so the shared key is an any-host write key on them.
# plugin.endpoints.sharedkeyallowed does not reach here - it gates the endpoints
# collector, not core - so turning it off leaves these open. This setting is the
# core counterpart. Default false = today's behaviour, unchanged.
LEGACY_SHARED_KEY_SETTING = 'collector.legacysharedkeyrefused'
# Declared here so the wording stays with the code that enforces it.
# build_default_settings (shopdb/core/api/settings.py) has to include this dict
# for the row to be seeded; until it does, the key is created on demand through
# the settings API and the absent-row default below is what applies.
LEGACY_SHARED_KEY_SETTING_DEFAULT = {
'key': LEGACY_SHARED_KEY_SETTING,
'value': 'false',
'valuetype': 'boolean',
'category': 'collector',
'description': 'Refuse the shared collector key on the legacy core routes '
'POST /api/collector/pc, /apps, /heartbeat and /bulk. Those '
'routes name their target PC in the body, so any holder of '
'the shared key can rewrite logged-in user, last boot time, '
'serial number and installed apps for ANY hostname. Turning '
'this on closes them to the shared key; a collector-scoped '
'managed token still works. Turn it on only after checking '
'the "Legacy collector route used with the shared key" '
'warnings in the log for a while: whatever still calls them '
'stops working the moment it is on. The shipped reporters '
'post to /api/collector/computers and '
'/api/collector/printers and are NOT affected. This should '
'follow the move to per-PC credentials, not precede it.',
}
def _computer_models():
"""Lazily import the computers plugin models.
@@ -94,8 +136,8 @@ def _token_is_collector(token, pluginname=None):
def _collector_managed_token(api_key):
"""Find the managed token backing this collector request, or None.
Two transports are accepted (GE-Enforce sends X-API-Key today; Bearer is
the standard PAT wire form):
Two transports are accepted (a fleet reporter that can only set a fixed
header sends X-API-Key; Bearer is the standard PAT wire form):
X-API-Key: the before_request PAT shim ignores this header, so resolve the
secret here.
Bearer: the shim already resolved+swapped it (the raw Bearer secret is
@@ -127,13 +169,17 @@ def _check_collector_auth(expected_key, pluginname=None):
api_key = request.headers.get('X-API-Key')
g.collectorauth = None
token = _collector_managed_token(api_key)
if token is not None and _token_is_collector(token, pluginname):
touch_apitoken_lastused(token)
g.collectorauth = AUTH_MANAGED_TOKEN
return None
if expected_key and api_key == expected_key \
and not _env_fallback_disabled(pluginname):
g.collectorauth = AUTH_SHARED_KEY
return None
# Fail-closed only on pure server misconfiguration: no env key AND the
@@ -160,6 +206,54 @@ def require_api_key(f):
return decorated
def _legacy_shared_key_refused():
"""True when the site has closed the legacy routes to the shared key.
Absent setting = false = the routes stay open, which is what every site
does today. Read tolerantly because the row can be created by hand through
the settings API, where it may land with valuetype string.
"""
from shopdb.core.models.setting import Setting
value = Setting.get(LEGACY_SHARED_KEY_SETTING, default=False)
if isinstance(value, str):
return value.strip().lower() in ('true', '1', 'yes')
return bool(value)
def require_api_key_legacy(f):
"""require_api_key plus the legacy-route shared-key gate.
These routes take their target hostname from the body, so the shared key is
an any-host write key on them and no per-PC credential narrows it. A site
that has finished the move to per-PC credentials can shut it off here
without waiting for the routes to be retired.
Every shared-key use is logged whether the gate is on or off, so a site can
read the warnings and learn what still depends on the key BEFORE flipping
it - otherwise turning it on is a guess.
"""
from shopdb.utils.clientip import client_ip
@wraps(f)
def decorated(*args, **kwargs):
error = _check_collector_auth(current_app.config.get('COLLECTOR_API_KEY'))
if error is not None:
return error
if getattr(g, 'collectorauth', None) == AUTH_SHARED_KEY:
if _legacy_shared_key_refused():
return error_response(
'SHARED_KEY_REFUSED',
'The shared key is not accepted on the legacy collector '
'routes; use a collector-scoped token',
http_code=401)
current_app.logger.warning(
'Legacy collector route used with the shared key: %s from %s',
request.path, client_ip(request))
return f(*args, **kwargs)
return decorated
def _find_pc(hostname):
"""Find a computer by hostname, falling back to its asset number.
@@ -241,11 +335,28 @@ def generic_collect(pluginname):
plugin, schema = plugins[pluginname]
# Per-plugin env key (fallback) OR a collector-scoped managed token. Header
# only (querystring fallback dropped, see require_api_key).
error = _check_collector_auth(_plugin_api_key(pluginname), pluginname)
if error is not None:
return error
# A schema that accepts agent credentials takes X-Endpoint-Credential and
# derives the calling asset from it. Otherwise: per-plugin env key or a
# collector-scoped managed token, header only (see require_api_key).
from shopdb.api import resolve_agent_credential, agent_shared_key_refused
acceptscredential = bool(schema.get('acceptsagentcredential'))
g.agentauth = None
g.agentassetid = None
secret = (request.headers.get(AGENT_CREDENTIAL_HEADER) or '').strip()
if secret and acceptscredential:
assetid = resolve_agent_credential(secret)
if assetid is None:
return error_response('CREDENTIAL_INVALID',
'Endpoint credential is unknown or revoked; '
'enroll again', http_code=401)
g.agentauth = 'credential'
g.agentassetid = assetid
else:
error = _check_collector_auth(_plugin_api_key(pluginname), pluginname)
if error is not None:
return error
g.agentauth = 'sharedkey'
payload = request.get_json(silent=True)
if not payload or not isinstance(payload, dict):
@@ -257,6 +368,12 @@ def generic_collect(pluginname):
return error_response(ErrorCodes.VALIDATION_ERROR,
f'{identityfield} is required')
if (acceptscredential and g.agentauth == 'sharedkey'
and agent_shared_key_refused(identityvalue)):
return error_response('SHARED_KEY_REFUSED',
'The shared key is not accepted for this '
'endpoint; use its credential', http_code=401)
try:
outcome = plugin.apply_collector_payload(payload)
except NotImplementedError:
@@ -264,6 +381,12 @@ def generic_collect(pluginname):
ErrorCodes.INTERNAL_ERROR,
f'Plugin {pluginname} does not implement apply_collector_payload',
http_code=500)
except PermissionError as exc:
# The payload names a different asset than the agent credential.
db.session.rollback()
return error_response('ASSET_MISMATCH', str(exc) or
'The payload names a different endpoint than '
'the credential', http_code=403)
except ValueError as exc:
# ValueError is the plugin's controlled validation signal; its message
# is safe to return to the caller.
@@ -314,7 +437,7 @@ def collector_schemas():
@collector_bp.route('/pc', methods=['POST'])
@require_api_key
@require_api_key_legacy
def update_pc_info():
"""Update one PC from the collection script (matched by hostname)."""
data = request.get_json()
@@ -352,7 +475,7 @@ def update_pc_info():
@collector_bp.route('/apps', methods=['POST'])
@require_api_key
@require_api_key_legacy
def update_installed_apps():
"""Update installed applications for a PC (matched by hostname)."""
data = request.get_json()
@@ -424,7 +547,7 @@ def update_installed_apps():
@collector_bp.route('/heartbeat', methods=['POST'])
@require_api_key
@require_api_key_legacy
def pc_heartbeat():
"""Record PC online status / heartbeat (single or batch)."""
data = request.get_json()
@@ -459,7 +582,7 @@ def pc_heartbeat():
@collector_bp.route('/bulk', methods=['POST'])
@require_api_key
@require_api_key_legacy
def bulk_update():
"""Bulk update multiple PCs at once."""
data = request.get_json()

View File

@@ -16,7 +16,6 @@ _TYPE_CATEGORY = {
'computer': 'PC',
'printer': 'Printer',
'network_device': 'Network',
'measuring_tool': 'Measuring Tool',
}
@@ -51,15 +50,14 @@ def _inactive_excluded():
.count())
COUNTEDTYPES = ('machine', 'computer', 'network_device', 'printer',
'measuring_tool')
COUNTEDTYPES = ('machine', 'computer', 'network_device', 'printer')
def _countedassets():
"""Base query for the assets the dashboard totals describe.
The status tiles and the type tiles MUST count the same population. They
did not: the totals summed five specific types and subtracted dual-bay
did not: the totals summed the COUNTEDTYPES above and subtracted dual-bay
secondaries, while the status counts took every asset row of any type with
no collapse. So "all assets 704" sat beside "in use 737", and both were
correct about different things - which makes them worse than either alone.
@@ -90,8 +88,7 @@ def get_dashboard():
pc_count = _count_by_type('computer')
network_count = _count_by_type('network_device')
printer_count = _count_by_type('printer')
measuringtool_count = _count_by_type('measuring_tool')
total = machine_count + pc_count + network_count + printer_count + measuringtool_count
total = machine_count + pc_count + network_count + printer_count
# Count by status over the SAME population as the totals above, so the
# tiles can be read against each other.
@@ -113,7 +110,6 @@ def get_dashboard():
'totalpc': pc_count,
'totalnetwork': network_count,
'totalprinter': printer_count,
'totalmeasuringtool': measuringtool_count,
'activeassets': status_dict.get('In Use', 0),
'inrepair': status_dict.get('In Repair', 0),
# What the totals actually cover, so the page can say so rather than
@@ -138,7 +134,6 @@ def get_dashboard():
'pcs': pc_count,
'networkdevices': network_count,
'printers': printer_count,
'measuringtools': measuringtool_count,
'total': total
},
'bystatus': status_dict,

View File

@@ -4,11 +4,21 @@ Served at /api/docs (relative to the mount). The Redoc bundle is vendored in
staticdocs/ so this works fully offline on the air-gapped prod box - no CDN.
The spec is docs/openapi.json in the repo (regenerate with
scripts/gen_openapi.py after API changes).
Two renderers over ONE spec. Redoc is read-only by design and is the default,
because what is published is a contract. Swagger UI at /api/docs/try adds the
one thing Redoc will not do: fire a real request from the page, against this
server, with whatever session the browser is already carrying. That is its
whole value and the whole reason it is off unless a site turns it on - see
API_DOCS_TRYIT_ENABLED in shopdb/config.py.
"""
import json
import os
from flask import Blueprint, Response, send_file, url_for
from markupsafe import escape
from flask import Blueprint, Response, current_app, send_file, url_for
docs_bp = Blueprint('docs', __name__)
@@ -19,21 +29,71 @@ _SPEC = os.path.join(_REPO_ROOT, 'docs', 'openapi.json')
_LLMS = os.path.join(_REPO_ROOT, 'docs', 'llms.txt')
_REDOC = os.path.join(_HERE, 'staticdocs', 'redoc.standalone.js')
# Vendored from swagger-ui-dist 5.33.0. TWO files out of the ~30 in that dist,
# because those are the two the page below actually loads:
# swagger-ui.css - the stylesheet. Its images are inline data: URIs,
# so it pulls nothing else. The trailing
# sourceMappingURL comment was stripped: the .map
# is not vendored, and left in place it turns every
# devtools open into a 404 on a box with no
# internet to fall back on.
# swagger-ui-bundle.min.js - the renderer plus the core presets, self-
# contained. NOT vendored beside it:
# swagger-ui-standalone-preset.js (the topbar with
# the "load any spec URL" box - this page serves
# one spec on purpose), the ES bundles (module
# builds of the same code), the .map files (~10 MB
# of source maps that only devtools reads),
# oauth2-redirect.html (no OAuth flow here), and
# the favicons.
# The upstream name is swagger-ui-bundle.js; the vendored copy carries .min.js,
# which is what it is - a minified single-line build. That suffix is also what
# scripts/check-naming-and-style.sh already filters vendored bundles by, so the
# style gate does not report thousands of hits in code nobody here wrote.
# swagger-ui.LICENSE.txt is the Apache-2.0 text that ships with the dist. It is
# never served; it is here because redistributing the bundle requires it.
_SWAGGER_CSS = os.path.join(_HERE, 'staticdocs', 'swagger-ui.css')
_SWAGGER_JS = os.path.join(_HERE, 'staticdocs', 'swagger-ui-bundle.min.js')
_TRYIT_ENDPOINT = 'docs.swagger_tryit'
def _tryit_is_mounted():
"""True when the try-it page exists on THIS app.
Reads the url map rather than the config flag on purpose: the routes are
bound once, at blueprint registration, so a config value changed after boot
would have the Redoc page advertise a link that answers 404.
"""
return _TRYIT_ENDPOINT in current_app.view_functions
@docs_bp.route('/', strict_slashes=False)
def docs_index():
"""Redoc page. url_for keeps the asset URLs correct under any mount."""
spec_url = url_for('docs.openapi_spec')
redoc_url = url_for('docs.redoc_js')
# Mentioned only when it is actually there. An unconditional note would be
# an advert for a 404 on every box that ships the shipped default.
tryit_note = ''
if _tryit_is_mounted():
tryit_note = (
'<p style="font:14px/1.4 system-ui,sans-serif;margin:0;'
'padding:10px 16px;background:#eef2f7;border-bottom:1px solid #cbd5e1">'
'This page is read-only. To send a real request against this '
'server, use <a href="%s">the try-it view</a>.</p>'
% url_for(_TRYIT_ENDPOINT)
)
page = (
'<!doctype html><html><head>'
'<title>ShopDB API</title>'
'<meta charset="utf-8">'
'<meta name="viewport" content="width=device-width, initial-scale=1">'
'<style>body{margin:0;padding:0}</style></head><body>'
'%s'
'<redoc spec-url="%s"></redoc>'
'<script src="%s"></script>'
'</body></html>' % (spec_url, redoc_url)
'</body></html>' % (tryit_note, spec_url, redoc_url)
)
return Response(page, mimetype='text/html')
@@ -59,3 +119,92 @@ def llms_txt():
if not os.path.isfile(_LLMS):
return Response('llms.txt not found', status=404, mimetype='text/plain')
return send_file(_LLMS, mimetype='text/plain')
def swagger_tryit():
"""Swagger UI over the SAME spec the Redoc page renders.
Every URL on the page comes from url_for, so the spec and both assets stay
same-origin and correct under a mount path (prod runs under /shopdb).
"""
spec_url = url_for('docs.openapi_spec')
css_url = url_for('docs.swagger_css')
js_url = url_for('docs.swagger_js')
redoc_page_url = url_for('docs.docs_index')
page = (
'<!doctype html><html><head>'
'<title>ShopDB API - try it</title>'
'<meta charset="utf-8">'
'<meta name="viewport" content="width=device-width, initial-scale=1">'
'<link rel="stylesheet" href="%(css)s">'
'<style>body{margin:0;padding:0}'
'.tryit-warn{font:14px/1.4 system-ui,sans-serif;background:#7f1d1d;'
'color:#fff;margin:0;padding:10px 16px}'
'.tryit-warn a{color:#fff}</style>'
'</head><body>'
'<p class="tryit-warn"><b>Execute sends a real request to this '
'server</b>, as whoever is signed in on this browser. A write '
'operation changes live data. The read-only contract is at '
'<a href="%(redoc)s">/api/docs</a>.</p>'
'<div id="swagger-ui"></div>'
'<script src="%(js)s"></script>'
'<script>\n'
'window.ui = SwaggerUIBundle({\n'
# json.dumps, NOT a bare "%s". SCRIPT_NAME is proxy-controlled and
# ProxyFix honours X-Forwarded-Prefix, so a mount path carrying a quote
# closed this string literal and executed what followed: SCRIPT_NAME of
# /a"+alert(1)+" rendered url: "/a"+alert(1)+"/api/docs/openapi.json".
# Escaping it as JSON is what makes the value data rather than code.
' url: %(spec)s,\n'
' dom_id: "#swagger-ui",\n'
' presets: [SwaggerUIBundle.presets.apis],\n'
' layout: "BaseLayout",\n'
' deepLinking: true,\n'
' filter: true,\n'
# 573 operations. Expanded by default, the page spends many seconds
# laying out before it answers a scroll.
' docExpansion: "none",\n'
' withCredentials: true,\n'
# THE air-gap setting. Left unset, Swagger UI renders a "valid" badge
# by handing this spec to the public swagger validator - an outbound
# request that cannot succeed here, and that would ship the internal
# API surface off the box if it ever could.
' validatorUrl: null\n'
'});\n'
'</script>'
'</body></html>'
% {'css': escape(css_url), 'js': escape(js_url),
'spec': json.dumps(spec_url),
'redoc': escape(redoc_page_url)}
)
return Response(page, mimetype='text/html')
def swagger_css():
"""Vendored Swagger UI stylesheet (offline)."""
return send_file(_SWAGGER_CSS, mimetype='text/css')
def swagger_js():
"""Vendored Swagger UI bundle (offline)."""
return send_file(_SWAGGER_JS, mimetype='application/javascript')
@docs_bp.record_once
def _mount_tryit(state):
"""Bind the try-it routes only when the flag is on for this app.
Binding at registration rather than checking the flag inside the view, so
that when it is off the routes DO NOT EXIST: the URL gets Flask's own 404
and confirms nothing, and the unauthenticated-GET sweep in
tests/test_core/test_public_read_surface.py never has to make an exception
for three routes that are only meant to be reachable on a dev box.
"""
if not state.app.config.get('API_DOCS_TRYIT_ENABLED'):
return
state.add_url_rule('/try', endpoint='swagger_tryit',
view_func=swagger_tryit, strict_slashes=False)
state.add_url_rule('/swagger-ui.css', endpoint='swagger_css',
view_func=swagger_css)
state.add_url_rule('/swagger-ui-bundle.min.js', endpoint='swagger_js',
view_func=swagger_js)

View File

@@ -0,0 +1,278 @@
"""Read-only employee directory API, backed by the external HR database.
WHY this is core and not a plugin. ShopDB never owns the staff directory: the
rows live in a separate HR database a site points at with EMPLOYEE_DB_*, and
this app only reads them. Several core features already depend on that read -
global search serves an `employee` domain, the audit log resolves an SSO to a
name, the login store fills a display name - and a notification aimed at a
person needs a way to pick that person. A read this many core surfaces depend
on cannot be optional, so it lives here.
Read-only on purpose: no create, update, delete or photo upload. Those belong
to whatever system owns the directory. The only writeable thing in the
neighbourhood is the `employee_photo_base_url` setting, which says where a
relative Picture path resolves.
Every route is gated on `employees.view` (declared in Permission.CORE_PERMISSIONS)
and returns only the columns in _FIELDS.
"""
import logging
from flask import Blueprint, request, current_app
# Core imports core directly, NOT through shopdb.api: the contract surface
# re-exports the two resolvers below, so importing it back from here would be a
# cycle that only works by accident of line order.
from shopdb.core.models import Setting
from shopdb.utils.responses import (success_response, error_response,
ErrorCodes)
from shopdb.utils.authz import require_permission
from shopdb.utils.employee_db import employee_connection
from shopdb.utils.queryargs import intarg
logger = logging.getLogger(__name__)
employees_bp = Blueprint('employees', __name__)
# Columns safe to expose. Anything the HR table holds beyond these stays there.
_FIELDS = 'SSO, First_Name, Last_Name, Team, Role, Picture'
# Where a bare relative Picture path resolves when employee_photo_base_url is
# unset. A site whose photos live on another host sets that setting to a full
# URL instead.
EMPLOYEE_PHOTO_STATIC_PREFIX = '/static/employees/'
def _external_photo_url(picture):
"""Turn an HR Picture value into a usable URL, or None.
Absolute URLs and already-rooted paths pass through untouched; a bare
relative path (e.g. 'Support/210009518.png') resolves under the configured
base.
"""
if not picture:
return None
text = str(picture).strip()
if not text:
return None
if text.startswith(('http://', 'https://', '/')):
return text
base = (Setting.get('employee_photo_base_url') or '').strip() \
or EMPLOYEE_PHOTO_STATIC_PREFIX
return base.rstrip('/') + '/' + text.lstrip('/')
def _hr_picture(sso):
"""Raw Picture value for an SSO. None on any miss."""
try:
conn = employee_connection()
with conn.cursor() as cur:
cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(sso),))
row = cur.fetchone()
conn.close()
return row.get('Picture') if row else None
except Exception:
return None
def resolve_employee_display_name(sso):
"""Display name ("First Last") for an SSO. None on any miss.
Used as a live fallback where only an SSO was stored - the shopfloor feed
does this for a notification imported without an employee name.
"""
if sso is None or not str(sso).isdigit():
return None
try:
conn = employee_connection()
with conn.cursor() as cur:
cur.execute(
'SELECT First_Name, Last_Name FROM employees WHERE SSO = %s',
(int(sso),))
row = cur.fetchone()
conn.close()
if row:
first = row.get('First_Name') or ''
last = row.get('Last_Name') or ''
name = f'{first.strip()} {last.strip()}'.strip()
if name:
return name
except Exception:
# Logged, not swallowed. This is the branch that fails on a live site -
# an unreachable HR host, a rotated credential, a renamed column - and a
# bare pass made all of those look identical to "no such employee": a
# bare SSO where a name should be, with nothing written down anywhere.
current_app.logger.exception(
'Employee directory lookup failed for SSO %s', sso)
# Fallback: a shopdb login account for this SSO (somebody the directory does
# not carry). Their name lives on the User record; usernames are the SSO.
try:
from shopdb.api import User
user = User.query.filter_by(username=str(sso)).first()
if user:
name = f'{(user.firstname or "").strip()} {(user.lastname or "").strip()}'.strip()
if name:
return name
except Exception:
current_app.logger.exception(
'User-account name fallback failed for SSO %s', sso)
return None
def resolve_employee_photo_url(sso, external_picture=None):
"""Display photo URL for an SSO, or None.
Pass external_picture when the caller already has the row, to avoid a
second query.
"""
if sso is None or not str(sso).isdigit():
return None
picture = external_picture if external_picture is not None else _hr_picture(sso)
return _external_photo_url(picture)
def _with_photo_url(employee):
"""Add the resolved photourl to an employee row dict."""
employee['photourl'] = resolve_employee_photo_url(
employee.get('SSO'), employee.get('Picture'))
return employee
@employees_bp.route('/search', methods=['GET'])
@require_permission('employees.view')
def search_employees():
"""Search the directory by name or SSO.
Query parameters:
- q: search query, at least 2 characters
- limit: max results (default 10, capped at 50)
"""
query = request.args.get('q', '').strip()
limit = min(intarg('limit', 10), 50)
if len(query) < 2:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Search query must be at least 2 characters'
)
# A multi-word query is a FULL NAME. Each word used to be matched against
# first name or last name on its own, so "Dana Reyes" found nobody: no
# single column contains that string. Somebody typing the whole name - what
# a person does before pressing Enter, rather than picking from the list -
# got an empty result and their entry became free text linked to nobody.
words = query.split()
try:
conn = employee_connection()
with conn.cursor() as cur:
if len(words) > 1:
clauses = ' AND '.join(
['(First_Name LIKE %s OR Last_Name LIKE %s)'] * len(words))
params = []
for word in words:
params.extend([f'%{word}%', f'%{word}%'])
params.append(limit)
cur.execute(f'''
SELECT {_FIELDS}
FROM employees
WHERE {clauses}
ORDER BY Last_Name, First_Name
LIMIT %s
''', tuple(params))
else:
cur.execute(f'''
SELECT {_FIELDS}
FROM employees
WHERE First_Name LIKE %s
OR Last_Name LIKE %s
OR CAST(SSO AS CHAR) LIKE %s
ORDER BY Last_Name, First_Name
LIMIT %s
''', (f'%{query}%', f'%{query}%', f'%{query}%', limit))
employees = cur.fetchall()
conn.close()
return success_response([_with_photo_url(e) for e in employees])
except Exception:
logger.exception('Employee search failed')
return error_response(
ErrorCodes.INTERNAL_ERROR,
'Employee lookup failed',
http_code=500
)
@employees_bp.route('/lookup/<sso>', methods=['GET'])
@require_permission('employees.view')
def lookup_employee(sso):
"""Look up a single employee by SSO."""
if not sso.isdigit():
return error_response(ErrorCodes.VALIDATION_ERROR, 'SSO must be numeric')
try:
conn = employee_connection()
with conn.cursor() as cur:
cur.execute(
f'SELECT {_FIELDS} FROM employees WHERE SSO = %s', (int(sso),))
employee = cur.fetchone()
conn.close()
if not employee:
return error_response(
ErrorCodes.NOT_FOUND,
f'Employee with SSO {sso} not found',
http_code=404
)
return success_response(_with_photo_url(employee))
except Exception:
logger.exception('Employee lookup failed for SSO %s', sso)
return error_response(
ErrorCodes.INTERNAL_ERROR,
'Employee lookup failed',
http_code=500
)
@employees_bp.route('/lookup', methods=['GET'])
@require_permission('employees.view')
def lookup_employees():
"""Look up several employees at once.
Query parameters:
- sso: comma-separated list of SSOs
"""
sso_list = request.args.get('sso', '')
ssos = [s.strip() for s in sso_list.split(',') if s.strip().isdigit()]
if not ssos:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'At least one valid SSO is required'
)
try:
conn = employee_connection()
with conn.cursor() as cur:
placeholders = ','.join(['%s'] * len(ssos))
cur.execute(
f'SELECT {_FIELDS} FROM employees WHERE SSO IN ({placeholders})',
[int(s) for s in ssos]
)
employees = cur.fetchall()
conn.close()
employees = [_with_photo_url(e) for e in employees]
names = ', '.join(
f"{(e['First_Name'] or '').strip()} {(e['Last_Name'] or '').strip()}".strip()
for e in employees
)
return success_response({'employees': employees, 'names': names})
except Exception:
logger.exception('Employee multi-lookup failed')
return error_response(
ErrorCodes.INTERNAL_ERROR,
'Employee lookup failed',
http_code=500
)

View File

@@ -2,6 +2,7 @@
import glob
import os
from urllib.parse import urlparse
from flask import Blueprint, request, current_app
from flask_jwt_extended import jwt_required
@@ -39,6 +40,16 @@ def _modelimage_dir():
return os.path.join(current_app.instance_path, 'modelimages')
def _validate_url_scheme(url):
"""Validate that URL uses safe scheme (prevents XSS via javascript: URIs)."""
if not url:
return True
parsed = urlparse(url)
if parsed.scheme and parsed.scheme not in ['http', 'https']:
raise ValueError(f"Invalid URL scheme '{parsed.scheme}'. Only http:// and https:// are allowed.")
return True
@models_bp.route('', methods=['GET'])
@require_permission('assets.view')
def list_models():
@@ -127,6 +138,13 @@ def create_model():
http_code=409
)
# Validate URL scheme to prevent XSS
try:
_validate_url_scheme(data.get('documentationurl'))
_validate_url_scheme(data.get('imageurl'))
except ValueError as e:
return error_response(ErrorCodes.VALIDATION_ERROR, str(e))
m = Model(
modelnumber=data['modelnumber'],
vendorid=data.get('vendorid'),
@@ -162,6 +180,15 @@ def update_model(model_id: int):
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
# Validate URL schemes to prevent XSS
try:
if 'documentationurl' in data:
_validate_url_scheme(data['documentationurl'])
if 'imageurl' in data:
_validate_url_scheme(data['imageurl'])
except ValueError as e:
return error_response(ErrorCodes.VALIDATION_ERROR, str(e))
for key in ['modelnumber', 'vendorid', 'modeltypeid', 'description', 'imageurl', 'documentationurl', 'notes', 'isactive']:
if key in data:
setattr(m, key, data[key])

View File

@@ -3,6 +3,7 @@
from flask import Blueprint, current_app, request
from flask_jwt_extended import jwt_required
from shopdb.plugins.base import renderable_settings_routes
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_role
@@ -42,6 +43,58 @@ def list_enabled_plugins():
return success_response(sorted(names))
@plugins_bp.route('/settings-routes', methods=['GET'])
@require_permission('settings.view')
def get_plugin_settings_routes():
"""Return settings routes contributed by all enabled plugins.
Plugins can register routes to add tabs/pages to settings hubs like
Model Catalog, Asset Config, etc. Frontend router merges these dynamically.
Gated on settings.view, the same permission list_plugins carries. It was
optional-auth and listed as a public read, on the stated reasoning that
unauthenticated pages (kiosks, TV displays) register plugin tabs before
login. Nothing does: the only caller in the frontend is
views/settings/ModelCatalogHub.vue, which sits behind requiresAuth and
requiresAdmin, and no kiosk route touches it. Open, it handed an anonymous
caller the enabled plugin list, the settings hub layout and the internal Vue
component paths - free reconnaissance about which modules a site runs. The
login page never calls this, so gating it costs nothing pre-login.
A route naming a hub that has no code to draw plugin tabs is DROPPED here,
loudly. Returning it would put an entry in a response that nothing reads:
the plugin author sees a correct declaration and an API that accepts it, and
the tab never appears anywhere. Dropping it costs the same missing tab and
leaves a line in the log that says which plugin, which hub, and what the
supported set is. See SETTINGS_HUBS_WITH_PLUGIN_TABS in shopdb/plugins/base.
Returns: { routes: [{hub, path, name, tab, component}, ...] }
"""
pm = current_app.extensions.get('plugin_manager')
if not pm:
return success_response({'routes': []})
routes = []
for plugin_name, plugin in pm.get_all_plugins().items():
# Only include routes from enabled plugins
if not pm.registry.is_enabled(plugin_name):
continue
if not hasattr(plugin, 'get_settings_routes'):
continue
# One broken plugin must not take every settings hub down with it.
try:
plugin_routes = plugin.get_settings_routes()
except Exception:
current_app.logger.exception(
'get_settings_routes failed for plugin %s; skipping it',
plugin_name)
continue
routes.extend(renderable_settings_routes(plugin_name, plugin_routes,
current_app.logger))
return success_response({'routes': routes})
@plugins_bp.route('/<name>', methods=['PUT'])
@jwt_required()
@require_role('admin')

View File

@@ -25,7 +25,14 @@ pluginui_bp = Blueprint('pluginui', __name__)
#
# 'details' is first and is the fallback: it holds what an asset IS, which is
# what a panel declaring nothing almost always contributes.
ASSET_PANEL_GROUPS = ('details', 'monitors', 'software', 'activity')
#
# This tuple is a THIRD copy of the tab vocabulary - RENDERED_GROUPS in
# PluginAssetPanels.vue and the tab list in PCDetail.vue are the other two.
# Adding a group means touching all three: a value missing here is rewritten to
# 'details' before the client ever sees it, so the panels quietly render in the
# wrong tab and the intended tab never appears. No error, no log line.
ASSET_PANEL_GROUPS = ('details', 'monitors', 'serviceprocess', 'reports',
'software', 'activity')
def _collect(hookname):
@@ -124,3 +131,104 @@ def asset_presentation():
"""
entries = _collect('get_asset_presentation')
return success_response(entries)
@pluginui_bp.route('/asset-type-descriptors', methods=['GET'])
@require_permission('assets.view')
def asset_type_descriptors():
"""Merge enabled plugins' asset-type subtype descriptors.
The Asset Type Hub uses these to render generic CRUD interfaces for
each plugin's subtype table (printertypes, machinetypes, etc.) without
importing plugin models or views.
Each descriptor maps to ONE asset type (from assettypes table) and
tells core:
- Which API endpoint handles the subtype CRUD
- Field names for ID, label, description, color
- Required permission
- Optional plugin-specific tabs (e.g., 'supplies' for printers)
Returns: List[Dict] sorted by assettype name
"""
pm = current_app.extensions.get('plugin_manager')
if not pm:
return success_response([])
descriptors = []
for name, plugin in pm.get_all_plugins().items():
if not pm.registry.is_enabled(name):
continue
try:
desc = plugin.get_asset_type_descriptor()
if desc is None:
continue
desc['plugin'] = name
descriptors.append(desc)
except Exception:
if current_app.config.get('DEBUG') or current_app.config.get('TESTING'):
raise
current_app.logger.exception('Plugin %s get_asset_type_descriptor failed', name)
# Validation: each descriptor must have required keys
required_keys = {'assettype', 'subtype_endpoint', 'subtype_id_field',
'subtype_label_field', 'subtype_description_field',
'subtype_color_field', 'edit_permission'}
validated = []
for desc in descriptors:
missing = required_keys - set(desc.keys())
if missing:
current_app.logger.error(
'Plugin %s descriptor missing keys: %s',
desc.get('plugin', 'unknown'), missing
)
if current_app.config.get('DEBUG') or current_app.config.get('TESTING'):
raise ValueError(f"Descriptor missing keys: {missing}")
continue
validated.append(desc)
validated.sort(key=lambda d: d.get('assettype', ''))
return success_response(validated)
@pluginui_bp.route('/core-type-descriptors', methods=['GET'])
@require_permission('assets.view')
def core_type_descriptors():
"""Return type descriptors for core-owned non-asset types.
These are types managed by core (not plugins) that fit the same
management pattern as asset subtypes: location types, notification types, etc.
Returns same descriptor format as asset-type-descriptors, with optional
'custom_component' field for types that need specialized UI.
"""
descriptors = [
{
'category': 'location',
'type_name': 'Location Type',
'type_name_plural': 'Location Types',
'subtype_endpoint': '/locations/types',
'subtype_id_field': 'locationtypeid',
'subtype_label_field': 'locationtype',
'subtype_description_field': 'description',
'subtype_color_field': 'color',
'optional_tabs': [],
'edit_permission': 'admin',
'group': 'other',
},
{
'category': 'notification',
'type_name': 'Notification Type',
'type_name_plural': 'Notification Types',
'subtype_endpoint': '/notifications/types',
'subtype_id_field': 'notificationtypeid',
'subtype_label_field': 'typename',
'subtype_description_field': 'typedescription',
'subtype_color_field': 'typecolor',
'optional_tabs': [],
'edit_permission': 'admin',
'group': 'other',
'custom_component': 'NotificationTypesTab',
},
]
return success_response(descriptors)

View File

@@ -6,16 +6,33 @@ its body by digest on push (the same model as application installer delivery).
The body never crosses the command wire and is never pre-staged on the endpoint;
the agent downloads it with its collector credential and verifies sha256.
Gated on the applications.* permissions - scripts are software delivery like
apps, so they ride the same operator grant rather than a separate permission.
Gated on endpoints.runscript, the same standalone grant that authorizes pushing
a script. These are NOT applications: an application installer is a vendor
artefact, while a script here is arbitrary PowerShell this site wrote that the
agent runs as SYSTEM. Gating library management on applications.* meant anyone
with the software-delivery grant could rewrite the bytes of a script a trusted
operator later pushes, which is a privilege escalation wearing a content-
management hat. The write and the push now require the same trust.
This TIGHTENS access, and there is deliberately NO backfill migration. The
operator scope grants applications.create/edit (they are edit verbs), so
operator-scope holders could reach this library before and cannot now. Do not
"fix" that by granting endpoints.runscript to applications.edit holders on
upgrade: it is in SCOPE_NEVER_GRANT precisely because it is a per-user checkbox
decision, and the same grant also authorizes the remote PUSH, so a backfill
would hand every operator SYSTEM code execution on the fleet. Admins are
unaffected via the role bypass; anyone else who genuinely needs the library
gets the checkbox in the user editor.
"""
import os
import re
from flask import Blueprint, request, current_app
from shopdb.extensions import db
from shopdb.core.models import Script, RUNCONTEXTS, RUNCONTEXT_SYSTEM, AuditLog
from shopdb.core.models import (Script, RUNCONTEXTS, RUNCONTEXT_SYSTEM,
ERRORMODES, ERRORMODE_DEFAULT, AuditLog)
from shopdb.utils.responses import (
success_response,
error_response,
@@ -32,6 +49,11 @@ SCRIPT_EXTENSIONS = {'.ps1'}
SCRIPT_URL_PREFIX = '/api/scripts/body/'
# A delivery script is small; cap well under the installer limit.
MAX_SCRIPT_BYTES = 1 * 1024 * 1024
# Owned by the endpoints plugin, which is where the risk is described in full
# ("ARBITRARY CODE, executes as SYSTEM"). Core names it as a string rather than
# importing it so this module stays importable with the plugin absent - the
# grant simply cannot be held by anyone if nothing seeds it.
SCRIPT_PERMISSION = 'endpoints.runscript'
def _script_dir():
@@ -42,7 +64,7 @@ scripts_bp = Blueprint('scripts', __name__)
@scripts_bp.route('', methods=['GET'])
@require_permission('applications.view')
@require_permission(SCRIPT_PERMISSION)
def list_scripts():
"""List scripts (active by default)."""
query = Script.query
@@ -55,7 +77,7 @@ def list_scripts():
@scripts_bp.route('/<int:script_id>', methods=['GET'])
@require_permission('applications.view')
@require_permission(SCRIPT_PERMISSION)
def get_script(script_id: int):
script = db.session.get(Script, script_id)
if not script:
@@ -73,8 +95,92 @@ def _validate_runcontext(value):
return value, None
def _validate_errormode(value):
"""Normalize/validate an errormode, defaulting to permissive when absent.
Defaults to ERRORMODE_DEFAULT rather than strict on purpose. Strict promotes
PowerShell's non-terminating errors to terminating ones, so a body that was
written to tolerate a failing cleanup or to probe with a cmdlet that errors
when the target is absent would begin aborting partway through. An absent
field therefore means "what this script already did", never a silent upgrade.
"""
if value is None:
return ERRORMODE_DEFAULT, None
value = str(value).strip().lower()
if value not in ERRORMODES:
return None, 'errormode must be one of: {}'.format(', '.join(sorted(ERRORMODES)))
return value, None
def _validate_captureoutput(value):
"""Normalize the "send output back on success" flag. Returns (value, error).
Absent means False: a script authored before this field existed never
returned its successful output to the server, and reading an omitted field
as True would start persisting output from bodies nobody reviewed with that
question in mind. Strings are accepted because the field arrives from a
form control that may send 'true'/'false' rather than a JSON boolean.
"""
if value is None:
return False, None
if isinstance(value, bool):
return value, None
normalized = str(value).strip().lower()
if normalized in ('true', '1', 'yes'):
return True, None
if normalized in ('false', '0', 'no', ''):
return False, None
return None, 'captureoutput must be true or false'
def _validate_workingdirectory(value):
"""Normalize the optional folder a script runs in. Returns (value, error).
Empty means "the agent's staging directory", which is the behaviour every
script had before this field existed. The purpose is to let a script sit
beside a corporate tool already installed on the endpoint and call it by
relative path.
The checks are shape checks, NOT a privilege boundary - a script body is
already arbitrary SYSTEM PowerShell and can change its own directory. They
exist so a typo fails here with an explanation instead of on the endpoint:
- must be an absolute local path (a drive letter), because a relative one
is meaningless without knowing what it would be relative TO
- no UNC. The stated use case is a tool already on this machine; a UNC
working directory resolves as the MACHINE ACCOUNT against a file server,
which moves where the binaries come from and is a different trust story
than "Intune put this here"
- no traversal segment, which only ever obscures what the path really is
"""
if value is None:
return None, None
value = str(value).strip().strip('"')
if not value:
return None, None
if value.startswith('\\\\') or value.startswith('//'):
return None, ('workingdirectory must be a local path on the endpoint, '
'not a UNC share')
normalized = value.replace('/', '\\')
if not re.match(r'^[A-Za-z]:\\', normalized):
return None, ('workingdirectory must be an absolute path including a '
r'drive letter, for example C:\Program Files\Sysinternals')
if '..' in normalized.split('\\'):
return None, 'workingdirectory must not contain a ".." segment'
if len(normalized) > 255:
return None, 'workingdirectory must be 255 characters or fewer'
# Trailing separator is noise, EXCEPT at a drive root: "C:\" trimmed to
# "C:" is drive-RELATIVE on Windows and would resolve to wherever the
# process happened to be on that drive, which is the opposite of pinning a
# folder. Keep the root form intact.
trimmed = normalized.rstrip('\\')
if len(trimmed) < 3:
trimmed = normalized[:3]
return trimmed, None
@scripts_bp.route('', methods=['POST'])
@require_permission('applications.create')
@require_permission(SCRIPT_PERMISSION)
def create_script():
"""Create a script catalog entry (body uploaded separately)."""
data = request.get_json(silent=True) or {}
@@ -85,6 +191,15 @@ def create_script():
return error_response(ErrorCodes.VALIDATION_ERROR,
f'A script named {name!r} already exists')
runcontext, err = _validate_runcontext(data.get('runcontext'))
if err:
return error_response(ErrorCodes.VALIDATION_ERROR, err)
workingdirectory, err = _validate_workingdirectory(data.get('workingdirectory'))
if err:
return error_response(ErrorCodes.VALIDATION_ERROR, err)
errormode, err = _validate_errormode(data.get('errormode'))
if err:
return error_response(ErrorCodes.VALIDATION_ERROR, err)
captureoutput, err = _validate_captureoutput(data.get('captureoutput'))
if err:
return error_response(ErrorCodes.VALIDATION_ERROR, err)
@@ -92,18 +207,24 @@ def create_script():
scriptname=name,
scriptdescription=data.get('scriptdescription'),
runcontext=runcontext,
workingdirectory=workingdirectory,
errormode=errormode,
captureoutput=captureoutput,
)
db.session.add(script)
db.session.commit()
AuditLog.log('created', 'Script', entityid=script.scriptid,
entityname=name,
changes={'runcontext': {'new': runcontext}})
changes={'runcontext': {'new': runcontext},
'workingdirectory': {'new': workingdirectory},
'errormode': {'new': errormode},
'captureoutput': {'new': captureoutput}})
db.session.commit()
return success_response(script.to_dict(), message='Script created', http_code=201)
@scripts_bp.route('/<int:script_id>', methods=['PUT'])
@require_permission('applications.edit')
@require_permission(SCRIPT_PERMISSION)
def update_script(script_id: int):
script = db.session.get(Script, script_id)
if not script:
@@ -127,6 +248,29 @@ def update_script(script_id: int):
if err:
return error_response(ErrorCodes.VALIDATION_ERROR, err)
script.runcontext = runcontext
if 'workingdirectory' in data:
workingdirectory, err = _validate_workingdirectory(
data.get('workingdirectory'))
if err:
return error_response(ErrorCodes.VALIDATION_ERROR, err)
script.workingdirectory = workingdirectory
if 'errormode' in data:
errormode, err = _validate_errormode(data.get('errormode'))
if err:
return error_response(ErrorCodes.VALIDATION_ERROR, err)
script.errormode = errormode
# Audited on change: this decides whether a successful run's output is
# persisted server-side, so who turned it on is worth being able to answer.
if 'captureoutput' in data:
captureoutput, err = _validate_captureoutput(data.get('captureoutput'))
if err:
return error_response(ErrorCodes.VALIDATION_ERROR, err)
if captureoutput != script.captureoutput:
AuditLog.log('updated', 'Script', entityid=script.scriptid,
entityname=script.scriptname,
changes={'captureoutput': {'old': script.captureoutput,
'new': captureoutput}})
script.captureoutput = captureoutput
if 'isactive' in data:
script.isactive = bool(data.get('isactive'))
@@ -135,7 +279,7 @@ def update_script(script_id: int):
@scripts_bp.route('/<int:script_id>', methods=['DELETE'])
@require_permission('applications.delete')
@require_permission(SCRIPT_PERMISSION)
def delete_script(script_id: int):
script = db.session.get(Script, script_id)
if not script:
@@ -149,7 +293,7 @@ def delete_script(script_id: int):
@scripts_bp.route('/<int:script_id>/body', methods=['POST'])
@require_permission('applications.edit')
@require_permission(SCRIPT_PERMISSION)
def upload_script_body(script_id: int):
"""Upload (or replace) the .ps1 body for a script.
@@ -196,15 +340,15 @@ def upload_script_body(script_id: int):
@scripts_bp.route('/body/<path:filename>', methods=['GET'])
@require_permission('applications.download')
@require_permission(SCRIPT_PERMISSION)
def download_script_body(filename: str):
"""Operator-facing script body download (JWT/RBAC).
The endpoint agent uses the collector-authenticated plugin route instead;
this one is for humans reviewing what a script does.
Gated on applications.download, not applications.view: this serves the
SYSTEM-context PowerShell a site pushes to its bays, and a guest may be
granted the metadata permission.
Same grant as the rest of the library: this serves the SYSTEM-context
PowerShell a site pushes to its bays, so reading it is not a lesser act
than listing it.
"""
return send_upload(_script_dir(), filename, as_attachment=True)

View File

@@ -212,8 +212,6 @@ def _get_asset_result(asset, query, relevance=None):
plugin_id = asset.network_device.networkdeviceid
elif asset_type_name == 'printer' and hasattr(asset, 'printer') and asset.printer:
plugin_id = asset.printer.printerid
elif asset_type_name == 'measuring_tool' and hasattr(asset, 'measuringtool') and asset.measuringtool:
plugin_id = asset.measuringtool.measuringtoolid
# Prefer a plugin's declared get_asset_presentation route (ADR-010): core
# stops hardcoding the plugin's route as each plugin declares one. Those
@@ -229,7 +227,6 @@ def _get_asset_result(asset, query, relevance=None):
'computer': f"/pcs/{plugin_id}",
'network_device': f"/network/{plugin_id}",
'printer': f"/printers/{plugin_id}",
'measuring_tool': f"/measuringtools/{plugin_id}",
}
url = url_map.get(asset_type_name, f"/assets/{asset.assetid}")
result_id = plugin_id
@@ -420,24 +417,13 @@ def _search_knowledgebase(query, search_term):
def _employee_rows(query):
"""Employee rows matching search_term, from the SELFHOSTED directory table
or the external HR DB per the employee_directory_mode setting. Returns dicts
shaped like the external query (SSO/First_Name/Last_Name/Team/Role)."""
settings = get_cached_settings()
mode = (settings.get('employee_directory_mode') or 'external').lower()
if mode == 'selfhosted':
try:
from plugins.employees.models import DirectoryEmployee
except ImportError:
return []
rows = DirectoryEmployee.query.filter(
_word_match(query, DirectoryEmployee.firstname,
DirectoryEmployee.lastname,
db.cast(DirectoryEmployee.sso, db.String))
).order_by(DirectoryEmployee.lastname, DirectoryEmployee.firstname).limit(10).all()
return [{'SSO': e.sso, 'First_Name': e.firstname or '',
'Last_Name': e.lastname or '', 'Team': e.team, 'Role': e.role}
for e in rows]
"""Employee rows matching search_term, read from the external HR database.
There is one mode, not two. A self-hosted directory table used to be the
alternative; it was never core, and with no plugin left to own that table
the branch could only ever return nothing. Returns dicts shaped like the
external query (SSO/First_Name/Last_Name/Team/Role).
"""
# External HR DB: shared env-backed connection helper; never hardcode creds.
# Word-AND so "First Last" matches (each word in any name/SSO field), not just
# the exact contiguous phrase. Parameterized - one LIKE triple per word.
@@ -461,7 +447,7 @@ def _employee_rows(query):
def _search_employees(query, search_term):
"""Search employees (selfhosted directory or external HR DB, per mode)."""
"""Search employees, read from the external HR database."""
results = []
try:
for emp in _employee_rows(query):
@@ -545,144 +531,7 @@ def _search_assets(query, search_term):
return results
def _search_measuringtools(query, search_term):
"""Search measuring tools, including the gaugelabreference identifier.
The generic asset search already matches number/name/serial across all asset
types; this gated searcher adds gaugelabreference (the gage tag the gage lab
searches by) and drops out when the measuringtools plugin is disabled.
"""
results = []
try:
_require_enabled('measuringtools')
from plugins.measuringtools.models import MeasuringTool
assets = db.session.query(Asset).join(
MeasuringTool, MeasuringTool.assetid == Asset.assetid
).options(
joinedload(Asset.assettype),
joinedload(Asset.location),
).filter(
Asset.isactive == True,
_word_match(query, Asset.assetnumber, Asset.name,
Asset.serialnumber, Asset.gaugelabreference)
).order_by(Asset.assetnumber, Asset.assetid).limit(15).all()
for asset in assets:
relevance = 15
if asset.assetnumber and query.lower() == asset.assetnumber.lower():
relevance = 100
elif asset.gaugelabreference and query.lower() == asset.gaugelabreference.lower():
relevance = 90
elif asset.serialnumber and query.lower() == asset.serialnumber.lower():
relevance = 85
elif asset.name and query.lower() in asset.name.lower():
relevance = 50
results.append(_get_asset_result(asset, query, relevance))
except ImportError:
pass # measuringtools plugin absent or disabled
except Exception as e:
_notefailure("Measuring tool", e)
return results
def _search_usbdevices(query, search_term):
"""Search USB devices by serial, asset tag and product name.
A USB device is NOT an asset - it lives in the usb plugin's own table - so
the generic asset search cannot see it and these records were unreachable
from search entirely. Serial number is the field people actually have in
hand: it is what is printed on the stick they are holding.
currentusername is deliberately NOT searched. It records who holds the
device, and making search a way to list what a named person has checked out
is a different feature from finding a device.
"""
results = []
try:
_require_enabled('usb')
from plugins.usb.models import USBDevice
devices = USBDevice.query.filter(
USBDevice.isactive == True,
_word_match(query, USBDevice.serialnumber, USBDevice.assetnumber,
USBDevice.label, USBDevice.productname)
).order_by(USBDevice.serialnumber,
USBDevice.usbdeviceid).limit(10).all()
for device in devices:
relevance = 20
if query.lower() == (device.serialnumber or '').lower():
relevance = 100
elif query.lower() == (device.assetnumber or '').lower():
relevance = 90
elif query.lower() == (device.label or '').lower():
relevance = 85
elif query.lower() in (device.label or '').lower():
relevance = 50
elif query.lower() in (device.productname or '').lower():
relevance = 40
results.append({
'type': 'usb_device',
'id': device.usbdeviceid,
'title': device.label or device.productname or device.serialnumber,
'subtitle': device.assetnumber or device.serialnumber,
'url': f'/usb/{device.usbdeviceid}',
'relevance': relevance,
})
except ImportError:
pass # usb plugin absent or disabled
except Exception as e:
_notefailure("USB device", e)
return results
def _search_printeditems(query, search_term):
"""Search printed items by bin code, gage-lab tag, name and description.
Printed items are their own records, not assets, so the generic asset search
never covered them. itemcode (the bin label, e.g. 3DP-0042) and gagelabtag
are both unique and both printed on physical labels, which makes them the
likeliest thing anyone types into search.
"""
results = []
try:
_require_enabled('printedparts')
from plugins.printedparts.models import PrintedItem
items = PrintedItem.query.filter(
PrintedItem.isactive == True,
_word_match(query, PrintedItem.itemcode, PrintedItem.gagelabtag,
PrintedItem.itemname, PrintedItem.itemdescription)
).order_by(PrintedItem.itemcode,
PrintedItem.printeditemid).limit(10).all()
for item in items:
relevance = 20
if query.lower() == (item.itemcode or '').lower():
relevance = 100
elif query.lower() == (item.gagelabtag or '').lower():
relevance = 95
elif query.lower() == (item.itemname or '').lower():
relevance = 90
elif query.lower() in (item.itemname or '').lower():
relevance = 50
subtitle = item.itemcode or item.gagelabtag
if item.binlocation:
subtitle = f'{subtitle} - {item.binlocation}' if subtitle else item.binlocation
results.append({
'type': 'printed_item',
'id': item.printeditemid,
'title': item.itemname,
'subtitle': subtitle,
'url': f'/printedparts/{item.printeditemid}',
'relevance': relevance,
})
except ImportError:
pass # printedparts plugin absent or disabled
except Exception as e:
_notefailure("Printed item", e)
return results
def _search_customfields(query, search_term):
@@ -1255,9 +1104,9 @@ def global_search():
# Every domain answers only to a caller who holds ITS permission.
#
# The route is gated on assets.view alone, and that used to be the whole
# check: one permission then queried twelve domains, so it read out of the
# staff directory, the USB checkout history, the notification log and the
# network inventory to anybody who could look up an asset number. The
# check: one permission then queried every domain at once, so it read out of
# the staff directory, the notification log and the network inventory to
# anybody who could look up an asset number. The
# SEARCH_DOMAINS settings are feature toggles a site sets, not authorization,
# and were never doing this job.
#
@@ -1274,9 +1123,6 @@ def global_search():
('kb.view', _search_knowledgebase),
('guides.view', _search_guides),
('employees.view', _search_employees),
('measuringtools.view', _search_measuringtools),
('usb.view', _search_usbdevices),
('printedparts.view', _search_printeditems),
('notifications.view', _search_notifications),
('computers.view', _search_hostnames),
]

View File

@@ -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

View File

@@ -5,6 +5,10 @@ server work: seeding common starter data and marking setup finished. Site,
plugin, and map configuration reuse the existing settings / plugins APIs.
"""
import hmac
import ipaddress
import os
from flask import Blueprint, request, current_app
from flask_jwt_extended import jwt_required
from werkzeug.security import generate_password_hash
@@ -29,6 +33,45 @@ STARTER_VENDORS = [
]
# Minimum length for the bootstrap administrator password. 12 characters is the
# shortest length that still resists offline cracking of a single leaked hash.
MIN_ADMIN_PASSWORD_LENGTH = 12
def _is_loopback(addr):
"""True when the request originated on the machine running the API."""
if not addr:
return False
try:
return ipaddress.ip_address(addr.split('%')[0]).is_loopback
except ValueError:
return False
def _bootstrap_allowed():
"""Proof of operator control for first-admin creation.
An empty users table is an ordering condition, not authorization: the API
serves requests before the first account exists on every fresh deploy, so a
remote caller could otherwise claim the instance during the install window.
Require either the one-time setup token (SETUP_TOKEN in config or the
environment, presented as the X-Setup-Token header) or a request coming from
loopback, i.e. the host the operator is installing on.
"""
token = str(current_app.config.get('SETUP_TOKEN')
or os.environ.get('SETUP_TOKEN') or '').strip()
if token:
supplied = (request.headers.get('X-Setup-Token') or '').strip()
return bool(supplied) and hmac.compare_digest(supplied, token)
return _is_loopback(request.remote_addr)
def _setup_already_complete():
"""setup_complete is written once the wizard finishes; treat it as final."""
row = Setting.query.filter_by(key='setup_complete').first()
return bool(row) and str(row.value).strip().lower() in ('true', '1', 'yes')
@setup_bp.route('/needs-admin', methods=['GET'])
def needs_admin():
"""True when no users exist yet - the app's very first run. Public so the
@@ -38,11 +81,18 @@ def needs_admin():
@setup_bp.route('/create-admin', methods=['POST'])
def create_admin():
"""Create the first admin. Only works while there are zero users, so it
can't be abused after setup. No auth (there is no one to auth as yet)."""
if User.query.count() > 0:
"""Create the first admin. Only works while there are zero users, setup has
not been marked complete, and the caller proves operator control (setup
token or a loopback request)."""
if User.query.count() > 0 or _setup_already_complete():
return error_response(ErrorCodes.FORBIDDEN,
'An account already exists; sign in instead.', http_code=403)
if not _bootstrap_allowed():
return error_response(
ErrorCodes.FORBIDDEN,
'First-admin creation requires the setup token (X-Setup-Token) '
'or a request from the server itself.',
http_code=403)
data = request.get_json() or {}
username = (data.get('username') or '').strip()
email = (data.get('email') or '').strip()
@@ -50,6 +100,10 @@ def create_admin():
if not (username and email and password):
return error_response(ErrorCodes.VALIDATION_ERROR,
'username, email and password are required')
if len(password) < MIN_ADMIN_PASSWORD_LENGTH:
return error_response(
ErrorCodes.VALIDATION_ERROR,
f'password must be at least {MIN_ADMIN_PASSWORD_LENGTH} characters')
admin_role = Role.query.filter_by(rolename='admin').first()
if not admin_role:
@@ -61,6 +115,8 @@ def create_admin():
user.roles.append(admin_role)
db.session.add(user)
db.session.commit()
current_app.logger.warning('First administrator %r created from %s',
username, request.remote_addr)
return success_response({'username': username}, message='Admin created', http_code=201)

View File

@@ -0,0 +1,745 @@
"""Backups API routes.
Admin-only surface over the site's backup sets. The heavy lifting (mysqldump,
restic snapshot of the instance tree, retention prune, integrity check) runs in
the one-shot backup container defined in docker-compose.backup.yml; this
blueprint is the read-mostly control plane the UI talks to.
Two recovery products stay firewalled, per the design notes:
- code rollback lives in git (tags/releases), NOT here
- data restore lives here, gated on schema compatibility via each set's manifest
Every backup set carries a manifest.json stamped with the core + plugin Alembic
revisions and the running image tag at capture time. A restore is a CHECKED
operation: current code must be at least the set's schema revision, so we never
load rows that reference a column the live schema has not applied yet.
"""
import json
import os
import subprocess
from datetime import datetime, timezone
from flask import Blueprint, current_app, request
from shopdb.core.models import AuditLog
from shopdb.core.models.setting import Setting
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_role
shopdbbackups_bp = Blueprint('shopdbbackups', __name__)
# Supported backup destination kinds. 'local' needs no credentials; 'smb' and
# 'aws' are ground-wired here so operators can configure them now, but their
# client libraries are held back until the security review clears them, so the
# runner treats a non-local destination as configured-but-not-yet-armed.
DESTINATION_KINDS = ('local', 'smb', 'aws')
# The setting keys that make up each destination's config, grouped by kind so
# the GET can surface only the relevant fields and the PUT can validate what it
# is allowed to write. Secret keys (password/secret/token in the name) are
# masked by the shared settings serializer, same convention as SMTP/SAML.
DESTINATION_FIELDS = {
'local': [],
'smb': [
'backup_smb_host',
'backup_smb_share',
'backup_smb_path',
'backup_smb_username',
'backup_smb_password',
],
'aws': [
'backup_aws_region',
'backup_aws_bucket',
'backup_aws_prefix',
'backup_aws_accesskeyid',
'backup_aws_secretaccesskey',
],
}
# Keys whose value must never leave the API in the clear. Mirrors the shared
# settings convention (_is_secret) so the two surfaces agree on what is secret.
_SECRET_TOKENS = ('password', 'secret', 'token')
SECRET_MASK = '********'
def _is_secret_key(key):
return any(token in key for token in _SECRET_TOKENS)
# Where the backup container drops each set and its manifest. Mounted read-only
# for the api container so the UI can enumerate sets without being able to
# mutate them; writes happen only from the backup one-shot.
BACKUPS_ROOT_ENV = 'SHOPDB_BACKUPS_ROOT'
DEFAULT_BACKUPS_ROOT = '/backups'
# Post-restore upgrade sentinels. The backup sidecar drops .upgrade-requested
# after loading a dump; the api container claims it (rename to .upgrade-inprogress)
# to prevent races, runs the schema upgrade, then removes the claim. A failure
# parks the sentinel as .upgrade-failed so operators can inspect before re-arming.
UPGRADE_SENTINEL = '.upgrade-requested'
UPGRADE_CLAIM = '.upgrade-inprogress'
UPGRADE_FAILED = '.upgrade-failed'
# Retention the backup container enforces; surfaced read-only so the UI can show
# "day 1 of 5 retained" without re-deriving the policy.
RETENTION = {
'keepdaily': 5,
'keepweekly': 4,
'keepmonthly': 6,
}
def _backups_root():
return os.environ.get(BACKUPS_ROOT_ENV, DEFAULT_BACKUPS_ROOT)
def _read_json(path):
"""Load a JSON file, or None when it is absent or unreadable."""
try:
with open(path, 'r', encoding='utf-8') as handle:
return json.load(handle)
except (OSError, ValueError):
return None
def _read_status(kind):
"""Return the live status dict for a run kind, or None when idle."""
root = _backups_root()
return _read_json(os.path.join(root, '.' + kind + '-status.json'))
def _read_history(kind):
"""Return the recorded run history for a kind, newest first."""
root = _backups_root()
entries = _read_json(os.path.join(root, 'history', kind + 's.json'))
if not isinstance(entries, list):
return []
return list(reversed(entries))
def _write_status(kind, phase, setname, message):
"""Seed a live status file so the UI shows movement the instant a run is
queued, before the watcher has picked up the sentinel."""
root = _backups_root()
stamp = datetime.now(timezone.utc).replace(tzinfo=None).isoformat() + 'Z'
payload = {
'kind': kind,
'phase': phase,
'setname': setname,
'message': message,
'updatedat': stamp,
}
try:
path = os.path.join(root, '.' + kind + '-status.json')
with open(path, 'w', encoding='utf-8') as handle:
json.dump(payload, handle)
except OSError:
pass
def _append_history(kind, outcome, setname, message):
"""Append one run record to the shared history the UI reads, newest last.
Mirrors status.sh's history_append so a finalize that completes in the api
(not the sidecar) still lands in the same history file the restore page
shows. Best-effort: history is a convenience log, never a gate.
"""
root = _backups_root()
stamp = datetime.now(timezone.utc).replace(tzinfo=None).isoformat() + 'Z'
entry = {
'kind': kind,
'outcome': outcome,
'setname': setname,
'message': message,
'at': stamp,
}
path = os.path.join(root, 'history', kind + 's.json')
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
entries = _read_json(path)
if not isinstance(entries, list):
entries = []
entries.append(entry)
with open(path, 'w', encoding='utf-8') as handle:
json.dump(entries, handle)
except OSError:
pass
def _claim_upgrade():
"""Atomically claim a pending upgrade to prevent races.
Returns the setname if this call won the claim, None otherwise. Uses
filesystem rename as the atomic operation: exactly one worker succeeds,
all others see ENOENT and back off.
"""
root = _backups_root()
sentinel = os.path.join(root, UPGRADE_SENTINEL)
claim = os.path.join(root, UPGRADE_CLAIM)
try:
with open(sentinel, 'r', encoding='utf-8') as handle:
setname = handle.read().strip()
except OSError:
return None
try:
os.rename(sentinel, claim)
return setname
except OSError:
return None
def _finalize_claimed(setname):
"""Run the post-restore schema upgrade and clear the claim.
Returns True on success, False on failure. A failure parks the sentinel
as .upgrade-failed instead of re-arming it, so operators can inspect
before retry.
"""
if not setname:
return True
root = _backups_root()
claim = os.path.join(root, UPGRADE_CLAIM)
failed_sentinel = os.path.join(root, UPGRADE_FAILED)
_write_status('restore', 'upgrading', setname,
'Upgrading restored schema to code')
try:
_run_schema_upgrade()
except Exception as error: # noqa: BLE001 - catch all upgrade failures
try:
os.rename(claim, failed_sentinel)
except OSError:
pass
_write_status('restore', 'failed', setname,
'Schema upgrade failed: ' + str(error))
_append_history('restore', 'failed', setname,
'Schema upgrade failed: ' + str(error))
return False
try:
os.remove(claim)
except OSError:
pass
_write_status('restore', 'done', setname, 'Restore complete')
_append_history('restore', 'success', setname, 'Restore complete')
return True
def _run_schema_upgrade():
"""Bring the just-restored database up to the running code, in-process.
Runs the same two idempotent steps every deploy runs: the core Alembic
upgrade to head, then each plugin chain via the plugin manager. Both are
no-ops when the data already matches the code head, so this is safe to call
even when the restored dump was already current. Uses the live app context
and its configured engine, so no subprocess or extra Flask CLI is needed.
"""
from flask_migrate import upgrade as alembic_upgrade
alembic_upgrade()
manager = current_app.extensions.get('plugin_manager')
if manager is None:
raise RuntimeError('Plugin manager not initialized; cannot upgrade plugins')
results = manager.upgrade_all_plugins()
failed = {name: status for name, status in (results or {}).items()
if status not in ('ok', 'no-migrations')}
if failed:
raise RuntimeError('Plugin upgrade failed: ' + json.dumps(failed))
def _read_manifest(setdir):
"""Load a set's manifest.json, or None when absent/unreadable.
A set without a readable manifest is not restorable (we cannot check its
schema), so callers treat None as "unverified" rather than trusting it.
"""
path = os.path.join(setdir, 'manifest.json')
try:
with open(path, 'r', encoding='utf-8') as handle:
return json.load(handle)
except (OSError, ValueError):
return None
def _current_schema_revisions():
"""Return the live Alembic head revisions (core + plugins) for this build.
Compared against a set's stamped revisions to decide restore compatibility.
Imported lazily so a missing alembic at request time degrades to an empty
map (everything reads as "unknown") instead of 500ing the list endpoint.
"""
try:
from alembic.config import Config
from alembic.script import ScriptDirectory
except ImportError:
return {}
root = current_app.root_path
migrations = os.path.join(os.path.dirname(root), 'migrations')
ini = os.path.join(migrations, 'alembic.ini')
if not os.path.isfile(ini):
return {}
try:
config = Config(ini)
# This is a Flask-Migrate project: alembic.ini carries no
# script_location (it is set at runtime), so ScriptDirectory.from_config
# would raise and leave every set reading as incompatible. Point it at
# the migrations directory - the same versions tree manifest.py scans.
config.set_main_option('script_location', migrations)
script = ScriptDirectory.from_config(config)
return {'core': list(script.get_heads())}
except Exception:
return {}
def _known_core_revisions():
"""Return every core Alembic revision this build knows, heads and ancestors.
Restore compatibility is now data-driven: a set restores when the running
code KNOWS the dump's data revision (so `flask db upgrade` can roll it
forward to head). Heads alone cannot answer that - a dump stamped at an
older-but-valid revision is perfectly restorable - so we walk the whole
script history. Lazy import + broad except keeps a missing/broken alembic
from 500ing the list endpoint (returns empty = everything reads unknown).
"""
try:
from alembic.config import Config
from alembic.script import ScriptDirectory
except ImportError:
return set()
root = current_app.root_path
migrations = os.path.join(os.path.dirname(root), 'migrations')
ini = os.path.join(migrations, 'alembic.ini')
if not os.path.isfile(ini):
return set()
try:
config = Config(ini)
config.set_main_option('script_location', migrations)
script = ScriptDirectory.from_config(config)
return {rev.revision for rev in script.walk_revisions()}
except Exception:
return set()
def _known_plugin_revisions():
"""Return known plugin revisions for each installed plugin.
Returns a dict mapping plugin name to set of known revision IDs. Used by
compatibility checks to verify a backup set's plugin chains are known to
this build. Plugins that fail to load or have no migrations are omitted.
"""
manager = current_app.extensions.get('plugin_manager')
if not manager:
return {}
try:
from alembic.config import Config
from alembic.script import ScriptDirectory
except ImportError:
return {}
known = {}
for plugin_name in manager.registry.get_all():
try:
migrations_dir = manager.migration_manager.get_migrations_dir(plugin_name)
if not migrations_dir:
continue
# Point alembic at this plugin's migrations directory
config = Config()
config.set_main_option('script_location', str(migrations_dir))
script = ScriptDirectory.from_config(config)
# Walk all revisions (heads and ancestors) for this plugin
revisions = {rev.revision for rev in script.walk_revisions()}
if revisions:
known[plugin_name] = revisions
except Exception:
# Skip plugins that fail to load or have malformed migrations
continue
return known
def _is_compatible(manifest, current):
"""Decide whether a set restores onto THIS build.
Data-driven gate (the fix for the FBJXBDB4 outage): the authoritative
signal is the set's `datarev` - the actual `alembic_version` of the dumped
DATABASE, not the code head that captured it. A restore is safe when the
running code KNOWS that revision (it is somewhere in this build's migration
history), because then the post-restore finalize can run `flask db upgrade`
to roll the loaded data forward to head. If the code does not know the
revision, the dump is from a NEWER schema than this build and would 500 on
restored rows, so we block until the code is upgraded.
Also checks plugin revisions when present: a set from a newer plugin chain
is refused even if the core revision is known, because plugin table schema
could be ahead of what this build understands.
Legacy sets predate `datarev` and `dataplugins`; fall back to the old
head-subset check on `schemarev` so they stay listable and restorable.
"""
if not manifest:
return False
# Check if manifest has datarev key (even if empty)
if 'datarev' in manifest:
datarev = manifest['datarev']
# Empty string means backup.sh couldn't read alembic_version (error case)
# Treat as incompatible - don't fall back to the legacy check this was
# introduced to replace
if not datarev:
return False
# Check core revision is known
known = _known_core_revisions()
if not known:
return False
if datarev not in known:
return False
# Check plugin revisions if present
dataplugins = manifest.get('dataplugins')
if dataplugins:
known_plugins = _known_plugin_revisions()
for plugin_name, plugin_rev in dataplugins.items():
# Refuse if this build doesn't have the plugin at all
if plugin_name not in known_plugins:
return False
# Refuse if the plugin exists but this revision is unknown
if plugin_rev not in known_plugins[plugin_name]:
return False
return True
# Legacy: fall back to schemarev head-subset check for pre-datarev sets
setrevs = set(manifest.get('schemarev', {}).get('core', []))
currevs = set(current.get('core', []))
if not setrevs or not currevs:
return False
# Compatible when the current heads cover every head the set was taken at.
return setrevs.issubset(currevs)
# Reserved entries under the backups root that are not backup sets: the run
# history store and any dotfile sentinels/status files the watcher drops.
_NON_SET_ENTRIES = ('history',)
def _list_sets():
"""Enumerate backup sets newest-first, each summarized for the list view."""
root = _backups_root()
if not os.path.isdir(root):
return []
current = _current_schema_revisions()
sets = []
for name in sorted(os.listdir(root), reverse=True):
if name in _NON_SET_ENTRIES or name.startswith('.'):
continue
setdir = os.path.join(root, name)
if not os.path.isdir(setdir):
continue
manifest = _read_manifest(setdir)
dbdump = os.path.join(setdir, 'db.sql.gz')
sizebytes = 0
for base, _dirs, files in os.walk(setdir):
for filename in files:
try:
sizebytes += os.path.getsize(os.path.join(base, filename))
except OSError:
pass
sets.append({
'snapshotid': name,
'takenat': (manifest or {}).get('takenat'),
'sizebytes': sizebytes,
'schemarev': (manifest or {}).get('schemarev', {}),
'imagetag': (manifest or {}).get('imagetag'),
'ischeckpassed': bool((manifest or {}).get('ischeckpassed')),
'hasmanifest': manifest is not None,
'hasdbdump': os.path.isfile(dbdump),
'iscompatible': _is_compatible(manifest, current),
})
return sets
@shopdbbackups_bp.route('', methods=['GET'])
@require_role('admin')
def list_backups():
"""List every retained backup set, newest first, with a compatibility flag."""
sets = _list_sets()
laststgood = next((s for s in sets if s['ischeckpassed']), None)
meta = {
'retention': RETENTION,
'lastgoodat': (laststgood or {}).get('takenat'),
'count': len(sets),
}
return success_response(data=sets, meta=meta)
@shopdbbackups_bp.route('/<snapshotid>', methods=['GET'])
@require_role('admin')
def get_backup(snapshotid):
"""Return one set's full manifest plus a resolved compatibility verdict."""
root = _backups_root()
setdir = os.path.join(root, os.path.basename(snapshotid))
if not os.path.isdir(setdir):
return error_response(ErrorCodes.NOT_FOUND, 'Backup set not found',
http_code=404)
manifest = _read_manifest(setdir)
current = _current_schema_revisions()
return success_response(data={
'snapshotid': os.path.basename(snapshotid),
'manifest': manifest,
'currentschemarev': current,
'iscompatible': _is_compatible(manifest, current),
'hasmanifest': manifest is not None,
})
@shopdbbackups_bp.route('', methods=['POST'])
@require_role('admin')
def trigger_backup():
"""Kick off an on-demand backup run.
The api container cannot run docker; it writes a sentinel the host-side
scheduler (or an operator running `docker compose run --rm backup`) picks
up. Returning isrunning=false with a clear message keeps the UI honest
rather than pretending the run started inside this process.
"""
root = _backups_root()
try:
os.makedirs(root, exist_ok=True)
sentinel = os.path.join(root, '.backup-requested')
stamp = datetime.now(timezone.utc).replace(tzinfo=None).isoformat() + 'Z'
with open(sentinel, 'w', encoding='utf-8') as handle:
handle.write(stamp)
except OSError as error:
return error_response(ErrorCodes.INTERNAL_ERROR,
'Could not queue a backup: ' + str(error),
http_code=500)
_write_status('backup', 'queued', '', 'Backup queued')
AuditLog.log('requested', 'Backup', entityname=stamp)
return success_response(
data={'isrunning': False, 'isqueued': True, 'requestedat': stamp},
message='Backup queued. The backup service runs it on its next pass.')
@shopdbbackups_bp.route('/<snapshotid>/restore', methods=['POST'])
@require_role('admin')
def restore_backup(snapshotid):
"""Queue a restore of a set, gated on schema compatibility.
We refuse outright when the set is incompatible (current code older than the
set's schema) so an operator cannot foot-gun a schema mismatch from the UI.
The actual restore is performed by scripts/backup/restore.sh in the backup
container; here we validate and record intent.
"""
root = _backups_root()
setdir = os.path.join(root, os.path.basename(snapshotid))
if not os.path.isdir(setdir):
return error_response(ErrorCodes.NOT_FOUND, 'Backup set not found',
http_code=404)
manifest = _read_manifest(setdir)
current = _current_schema_revisions()
if not _is_compatible(manifest, current):
return error_response(
ErrorCodes.CONFLICT,
'This backup was taken on a newer schema than the running code. '
'Upgrade the code to at least the set\'s revision, run '
'`flask db upgrade` and `flask plugin upgrade-all`, then retry.',
details={'currentschemarev': current,
'setschemarev': (manifest or {}).get('schemarev', {})},
http_code=409)
try:
sentinel = os.path.join(root, '.restore-requested')
stamp = datetime.now(timezone.utc).replace(tzinfo=None).isoformat() + 'Z'
with open(sentinel, 'w', encoding='utf-8') as handle:
handle.write(os.path.basename(snapshotid))
except OSError as error:
return error_response(ErrorCodes.INTERNAL_ERROR,
'Could not queue the restore: ' + str(error),
http_code=500)
_write_status('restore', 'queued', os.path.basename(snapshotid),
'Restore queued')
AuditLog.log('restore-requested', 'Backup',
entityname=os.path.basename(snapshotid))
return success_response(
data={'isqueued': True, 'snapshotid': os.path.basename(snapshotid)},
message='Restore queued and validated against the live schema.')
@shopdbbackups_bp.route('/finalize-restore', methods=['POST'])
@require_role('admin')
def finalize_restore():
"""Run the post-restore schema upgrade in the app context.
The restore itself runs in the Flask-less backup sidecar, which loads the
dump and then drops an .upgrade-requested sentinel because it cannot run
'flask db upgrade' itself. This endpoint is the api-side half: it brings the
just-restored DATA up to the running CODE with the exact idempotent commands
the deploy path uses, then stamps the final 'done'. Without it a restored
dump that sits at an older revision leaves every newer-migration table
throwing 1054 Unknown column.
Supports re-arming a failed upgrade: if .upgrade-failed exists, moves it back
to .upgrade-requested so the operator can retry after fixing the issue.
Idempotent and safe to call when nothing is pending: if there is no sentinel
it reports there was nothing to finalize. Uses an atomic claim mechanism to
prevent races when multiple workers poll this endpoint simultaneously.
"""
root = _backups_root()
# Check if backup store is configured
if not os.path.isdir(root):
return error_response(ErrorCodes.NOT_FOUND,
'Backup store not configured',
http_code=404)
# Support re-arming a failed upgrade
failed_sentinel = os.path.join(root, UPGRADE_FAILED)
if os.path.isfile(failed_sentinel):
try:
with open(failed_sentinel, 'r', encoding='utf-8') as handle:
setname = handle.read().strip()
# Re-arm by moving failed back to requested
sentinel = os.path.join(root, UPGRADE_SENTINEL)
os.rename(failed_sentinel, sentinel)
return success_response(
data={'retried': True, 'snapshotid': setname},
message='Failed upgrade re-armed for retry.')
except OSError as error:
return error_response(ErrorCodes.INTERNAL_ERROR,
'Could not re-arm failed upgrade: ' + str(error),
http_code=500)
# Check if there's a pending upgrade sentinel (but don't claim it)
# The watcher/background worker will claim and run it
sentinel = os.path.join(root, UPGRADE_SENTINEL)
if os.path.isfile(sentinel):
try:
with open(sentinel, 'r', encoding='utf-8') as handle:
setname = handle.read().strip()
return success_response(
data={'queued': True, 'snapshotid': setname},
message='Schema upgrade queued.')
except OSError:
pass
return success_response(
data={'queued': False},
message='No restore is awaiting schema upgrade.')
@shopdbbackups_bp.route('/status', methods=['GET'])
@require_role('admin')
def get_status():
"""Return the live status of the most recent backup and restore runs.
The watcher writes these files as it moves through each phase; the UI polls
this endpoint to drive the restore progress spinner and phase label. Either
value is None when that kind of run has never been requested.
"""
return success_response(data={
'backup': _read_status('backup'),
'restore': _read_status('restore'),
})
@shopdbbackups_bp.route('/history', methods=['GET'])
@require_role('admin')
def get_history():
"""Return the recorded history of backup and restore runs, newest first.
These are operational run logs written by the backup container onto the
shared volume, not the audit trail (AuditLog records the requested intent).
"""
return success_response(data={
'backups': _read_history('backup'),
'restores': _read_history('restore'),
})
@shopdbbackups_bp.route('/settings', methods=['GET'])
@require_role('admin')
def get_backup_settings():
"""Return destination + retention config for the settings pane.
The destination kind and its (non-secret) fields come from the settings
store so an operator can configure where backups ship without editing env
or redeploying. Secret fields (SMB password, AWS secret key) are never
returned in the clear: we send a mask when a value is set and an empty
string when it is not, matching the shared settings API convention.
'smb' and 'aws' are configurable now but their client libraries are held
back pending the security review, so we flag those destinations as armed:
false to tell the UI the config is stored but not yet acting.
"""
kind = Setting.get('backup_destination_kind', 'local')
if kind not in DESTINATION_KINDS:
kind = 'local'
fields = {}
for key in DESTINATION_FIELDS.get(kind, []):
raw = Setting.get(key, '')
if _is_secret_key(key):
fields[key] = SECRET_MASK if raw else ''
else:
fields[key] = raw or ''
return success_response(data={
'destinationkind': kind,
'destinationkinds': list(DESTINATION_KINDS),
'fields': fields,
'isarmed': kind == 'local',
'retention': RETENTION,
'backupsroot': _backups_root(),
})
@shopdbbackups_bp.route('/settings', methods=['PUT'])
@require_role('admin')
def update_backup_settings():
"""Persist the backup destination kind and its credential/config fields.
Only keys that belong to the chosen destination are accepted, so a stray
payload cannot write arbitrary settings rows through this surface. A secret
field submitted as the mask placeholder means "leave unchanged" - the
client only ever received the mask, so we never overwrite a real secret
with it.
"""
data = request.get_json(silent=True) or {}
kind = data.get('destinationkind', Setting.get('backup_destination_kind', 'local'))
if kind not in DESTINATION_KINDS:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'destinationkind must be one of: ' + ', '.join(DESTINATION_KINDS))
Setting.set('backup_destination_kind', kind, valuetype='string',
category='backup')
incoming = data.get('fields', {}) or {}
allowed = set(DESTINATION_FIELDS.get(kind, []))
for key, value in incoming.items():
if key not in allowed:
continue
if _is_secret_key(key) and value == SECRET_MASK:
continue
Setting.set(key, value if value is not None else '',
valuetype='string', category='backup')
AuditLog.log('updated', 'BackupSettings', entityname=kind, changes={
'destinationkind': kind,
})
return get_backup_settings()

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,7 @@
"""Support Teams API endpoints - teams and their nested contacts."""
from urllib.parse import urlparse
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
@@ -19,6 +21,16 @@ from shopdb.utils.authz import require_permission
supportteams_bp = Blueprint('supportteams', __name__)
def _validate_url_scheme(url):
"""Validate that URL uses safe scheme (prevents XSS via javascript: URIs)."""
if not url:
return True
parsed = urlparse(url)
if parsed.scheme and parsed.scheme not in ['http', 'https']:
raise ValueError(f"Invalid URL scheme '{parsed.scheme}'. Only http:// and https:// are allowed.")
return True
@supportteams_bp.route('', methods=['GET'])
@require_permission('assets.view')
def list_support_teams():
@@ -62,6 +74,13 @@ def create_support_team():
f"Support team '{data['teamname']}' already exists",
http_code=409)
# Validate URL schemes to prevent XSS
try:
_validate_url_scheme(data.get('teamurl'))
_validate_url_scheme(data.get('webhookurl'))
except ValueError as e:
return error_response(ErrorCodes.VALIDATION_ERROR, str(e))
team = SupportTeam(
teamname=data['teamname'],
teamurl=data.get('teamurl'),
@@ -101,6 +120,15 @@ def update_support_team(team_id: int):
f"Support team '{data['teamname']}' already exists",
http_code=409)
# Validate URL schemes to prevent XSS
try:
if 'teamurl' in data:
_validate_url_scheme(data['teamurl'])
if 'webhookurl' in data:
_validate_url_scheme(data['webhookurl'])
except ValueError as e:
return error_response(ErrorCodes.VALIDATION_ERROR, str(e))
for key in ['teamname', 'teamurl', 'webhookurl', 'isactive']:
if key in data:
setattr(team, key, data[key])

View File

@@ -7,13 +7,82 @@ from werkzeug.security import generate_password_hash
from shopdb.extensions import db
from shopdb.core.models import (
User, Role, Permission, AuditLog, full_permission_catalog,
guest_grantable_permissions)
guest_grantable_permissions, SCOPE_ENDPOINT_ACTIONS,
SCOPES_DENIED_ENDPOINT_ACTIONS)
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_role
users_bp = Blueprint('users', __name__)
def _check_scope_grants(rolenames, permissionnames):
"""Refuse direct grants a prebuilt scope is defined as never holding.
Returns an error_response, or None when the combination is allowed.
This is the server-side half of the scope dropdown, and it is the half that
is actually load-bearing. The UI greys out the endpoint action checkboxes
for a reporter, but a greyed-out checkbox is a suggestion: the same account
can be created with a restart grant by anyone willing to POST JSON. Reporter
was specified as holding no action capability EVEN IF expressly permitted,
and only a check on the write path can make that true.
Enforced here rather than in User.haspermission() on purpose. A subtractive
ceiling in the read predicate would sit in the hot path of every
permission-gated route in the app and change the meaning of a grant
everywhere, to fix a combination that should simply never have been stored.
Refusing the write is the narrow version: bad state cannot get in, and the
thousands of existing reads keep their current semantics.
Note this deliberately does NOT restrict the admin role, which bypasses
permission checks by rule, nor any site-created custom role. It constrains
only the scopes that were seeded with a documented promise about what they
hold.
"""
denied_scopes = SCOPES_DENIED_ENDPOINT_ACTIONS & set(rolenames)
if not denied_scopes:
return None
conflicts = sorted(set(permissionnames) & set(SCOPE_ENDPOINT_ACTIONS))
if not conflicts:
return None
scope = sorted(denied_scopes)[0]
return error_response(
ErrorCodes.VALIDATION_ERROR,
f"The {scope} scope cannot hold endpoint actions "
f"({', '.join(conflicts)}). Assign the technician or operator scope "
f"if this user needs to act on endpoints.")
def _resolve_directpermissions(names):
"""Resolve permission names to rows, rejecting any that do not exist.
Returns (permissions, error_response). A None permissions value with no
error means the caller did not mention direct grants at all, which is not
the same as sending an empty list - that clears them.
Unknown names are refused rather than dropped. Silently ignoring a typo
would report success while granting nothing, and the admin would believe
the capability was handed out.
"""
if names is None:
return None, None
if not isinstance(names, list):
return None, error_response(
ErrorCodes.VALIDATION_ERROR,
'directpermissions must be a list of permission names')
if not names:
return [], None
wanted = {str(n) for n in names}
permissions = Permission.query.filter(Permission.name.in_(wanted)).all()
missing = sorted(wanted - {p.name for p in permissions})
if missing:
return None, error_response(
ErrorCodes.VALIDATION_ERROR,
'Unknown permissions: ' + ', '.join(missing))
return permissions, None
@users_bp.route('', methods=['GET'])
@jwt_required()
@require_role('admin')
@@ -61,6 +130,28 @@ def create_user():
if User.query.filter_by(email=data['email']).first():
return error_response(ErrorCodes.CONFLICT, 'Email already exists', http_code=409)
# Resolve and VALIDATE roles and grants before touching the User object.
#
# Ordering is deliberate. Assigning user.roles first put the relationship
# into the session's autoflush path while the User itself was still
# unattached, which SQLAlchemy warned about ("Object of type <User> not in
# session, add operation along 'Role.users' won't proceed"). Nothing broke,
# because the refusal returns before db.session.add(). But it left a live
# trap: move or add a query below this point and the autoflush becomes a
# real write of a half-built user, surfacing later as an orphan row nobody
# can explain. Validate everything, then build.
role_ids = data.get('roles', [])
roles = Role.query.filter(Role.roleid.in_(role_ids)).all() if role_ids else []
permissions, err = _resolve_directpermissions(data.get('directpermissions'))
if err:
return err
err = _check_scope_grants(
[r.rolename for r in roles],
[p.name for p in (permissions or [])])
if err:
return err
# Admin-created accounts are forced to change the password on first login
# unless the admin explicitly opts out.
mustchange = data.get('mustchangepassword', True)
@@ -75,12 +166,13 @@ def create_user():
mustchangepassword=bool(mustchange)
)
# Assign roles
role_ids = data.get('roles', [])
if role_ids:
roles = Role.query.filter(Role.roleid.in_(role_ids)).all()
if roles:
user.roles = roles
# Direct per-user permission grants, held alongside (not instead of) roles.
if permissions is not None:
user.directpermissions = permissions
db.session.add(user)
# Audit log
@@ -181,6 +273,39 @@ def update_user(userid: int):
changes['roles'] = {'old': old_roles, 'new': new_roles}
user.roles = roles
# Direct grants are audited by NAME rather than id: the point of the
# audit row is to be readable years later, when a permission id means
# nothing to the person reading it.
if 'directpermissions' in data:
permissions, err = _resolve_directpermissions(
data['directpermissions'])
if err:
return err
old_direct = user.getdirectpermissionnames()
new_direct = sorted(p.name for p in (permissions or []))
if old_direct != new_direct:
changes['directpermissions'] = {
'old': old_direct, 'new': new_direct}
user.directpermissions = permissions or []
# Validate the COMBINATION, after both assignments and against the
# user's effective state rather than only what this request mentioned.
# A payload may change roles without mentioning grants, or grants
# without mentioning roles; checking only the supplied half would let a
# request that demotes an operator to reporter keep the restart grant
# they already held, which is exactly the state the scope promises is
# impossible. Reading back off `user` covers all three shapes.
#
# Safe to check after mutating because nothing has committed yet - the
# early return leaves the session to be rolled down by the request
# teardown, so a refused combination is never persisted.
err = _check_scope_grants(
[r.rolename for r in user.roles],
user.getdirectpermissionnames())
if err:
db.session.rollback()
return err
# Unlock user
if data.get('unlock'):
user.lockeduntil = None
@@ -237,6 +362,21 @@ def delete_user(userid: int):
# revoke their API tokens outright, and DETACH their audit history
# (userid -> NULL) - the log rows themselves are kept, entityname and
# details still tell the story.
#
# What a departing user leaves behind is deliberately NOT undone. Policies
# carry no FK to users, so configuration and business policies they authored
# keep working; the audit rows keep username, which is denormalized on
# auditlogs for exactly this reason. Leaving is not a reason to unpick
# someone's work.
#
# TODO (future): offer to EXPORT this user's audit history at the moment of
# removal, and on a significant scope reduction too. Detaching costs the
# userid link, so after this runs their trail is only reachable by matching
# the username string - which is fragile if the name is ever reused. An
# export taken at the point of change preserves the joined-up record while
# it still exists. Deactivating (isactive=False) instead of deleting keeps
# the userid link intact and is the better choice when the account may ever
# need to be audited in depth.
from shopdb.core.models import ApiToken
ApiToken.query.filter_by(userid=userid).delete(synchronize_session=False)
AuditLog.query.filter_by(userid=userid).update(
@@ -452,6 +592,14 @@ def user_to_dict(user: User) -> dict:
'lastlogindate': user.lastlogindate.isoformat() + 'Z' if user.lastlogindate else None,
'failedlogins': user.failedlogins,
'roles': [{'roleid': r.roleid, 'rolename': r.rolename, 'color': r.color} for r in user.roles],
# Split deliberately. directpermissions is what is STORED on the user and
# is what the editor's checkboxes bind to; permissions is the EFFECTIVE
# set including everything the roles supply, which is what a reader needs
# to answer "can this person do X". A checkbox bound to the effective set
# would appear ticked for a capability the user holds through a role, and
# unticking it would silently do nothing.
'directpermissions': user.getdirectpermissionnames(),
'permissions': sorted(user.getpermissions()),
'createddate': user.createddate.isoformat() + 'Z' if user.createddate else None,
'modifieddate': user.modifieddate.isoformat() + 'Z' if user.modifieddate else None
}