Promote the 2026-08-30 review security fixes and supplies ordering to main
This commit is contained in:
@@ -656,13 +656,17 @@ def upload_application_package(app_id: int):
|
||||
|
||||
|
||||
@applications_bp.route('/package/<path:filename>', methods=['GET'])
|
||||
@require_permission('applications.view')
|
||||
@require_permission('applications.download')
|
||||
def serve_application_package(filename):
|
||||
"""Download an uploaded installer.
|
||||
|
||||
Authenticated, unlike the image: this is licensed vendor software, and an
|
||||
open URL would publish it to anything that can reach the site. Always sent
|
||||
as an attachment so a browser saves it rather than trying to render it.
|
||||
|
||||
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.
|
||||
"""
|
||||
return send_upload(_apppackage_dir(), filename, as_attachment=True)
|
||||
|
||||
|
||||
@@ -196,11 +196,15 @@ def upload_script_body(script_id: int):
|
||||
|
||||
|
||||
@scripts_bp.route('/body/<path:filename>', methods=['GET'])
|
||||
@require_permission('applications.view')
|
||||
@require_permission('applications.download')
|
||||
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.
|
||||
"""
|
||||
return send_upload(_script_dir(), filename, as_attachment=True)
|
||||
|
||||
@@ -18,7 +18,7 @@ from shopdb.core.models import (
|
||||
from shopdb.core.api.settings import get_cached_settings
|
||||
from shopdb.core.services.spellfix import Vocabulary
|
||||
from shopdb.utils.responses import success_response
|
||||
from shopdb.utils.authz import require_permission
|
||||
from shopdb.utils.authz import require_permission, caller_holds
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1233,22 +1233,42 @@ def global_search():
|
||||
results = []
|
||||
search_term = f'%{query}%'
|
||||
|
||||
# Run all search domains
|
||||
results.extend(_search_applications(query, search_term))
|
||||
results.extend(_search_knowledgebase(query, search_term))
|
||||
results.extend(_search_guides(query, search_term))
|
||||
results.extend(_search_employees(query, search_term))
|
||||
results.extend(_search_assets(query, search_term))
|
||||
results.extend(_search_measuringtools(query, search_term))
|
||||
results.extend(_search_usbdevices(query, search_term))
|
||||
results.extend(_search_printeditems(query, search_term))
|
||||
results.extend(_search_customfields(query, search_term))
|
||||
results.extend(_search_notifications(query, search_term))
|
||||
results.extend(_search_hostnames(query, search_term))
|
||||
results.extend(_search_vendor_model_type(query, search_term))
|
||||
# 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
|
||||
# SEARCH_DOMAINS settings are feature toggles a site sets, not authorization,
|
||||
# and were never doing this job.
|
||||
#
|
||||
# caller_holds rather than require_permission: a search that refuses the
|
||||
# whole request because one domain is off-limits is useless. Each domain
|
||||
# simply contributes nothing.
|
||||
domains = [
|
||||
('assets.view', _search_assets),
|
||||
# Emit asset results routed to an asset page, so they belong to the
|
||||
# same grant as the assets themselves.
|
||||
('assets.view', _search_customfields),
|
||||
('assets.view', _search_vendor_model_type),
|
||||
('applications.view', _search_applications),
|
||||
('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),
|
||||
]
|
||||
for permission, search in domains:
|
||||
if caller_holds(permission):
|
||||
results.extend(search(query, search_term))
|
||||
|
||||
# IP-specific searches
|
||||
if classification['is_ip']:
|
||||
# IP-specific searches. An address and the subnet it sits in are network
|
||||
# inventory, and knowing what answers on 10.20.x is a different question
|
||||
# from looking up an asset number.
|
||||
if classification['is_ip'] and caller_holds('network.view'):
|
||||
results.extend(_search_by_ip(query, search_term))
|
||||
results.extend(_search_subnets(query))
|
||||
|
||||
|
||||
@@ -351,19 +351,20 @@ def update_role(roleid: int):
|
||||
# Update permissions
|
||||
if 'permissions' in data and role.rolename != 'admin':
|
||||
# The guest role is anyone with no account, so what it may hold is
|
||||
# bounded: reads, plus the handful of writes a plugin has explicitly
|
||||
# declared safe to do anonymously. Refused at the door rather than
|
||||
# warned about - a checkbox that quietly makes deleting an asset
|
||||
# anonymous is not a warning anybody reads twice.
|
||||
# bounded to what core or a plugin has explicitly declared grantable.
|
||||
# Refused at the door rather than warned about - a checkbox that
|
||||
# quietly makes deleting an asset anonymous is not a warning anybody
|
||||
# reads twice.
|
||||
if role.rolename == Role.GUEST:
|
||||
allowed = guest_grantable_permissions()
|
||||
refused = sorted(set(data['permissions']) - allowed)
|
||||
if refused:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'The guest role cannot hold {}. A guest may be granted any '
|
||||
'view permission, and only writes a plugin has declared '
|
||||
'safe to do without an account.'.format(', '.join(refused)))
|
||||
'The guest role cannot hold {}. A guest may only be '
|
||||
'granted permissions core or the owning plugin has '
|
||||
'declared grantable, which is a code change rather than a '
|
||||
'setting.'.format(', '.join(refused)))
|
||||
old_perms = [p.name for p in role.permissions]
|
||||
perms = Permission.query.filter(Permission.name.in_(data['permissions'])).all()
|
||||
new_perms = [p.name for p in perms]
|
||||
|
||||
Reference in New Issue
Block a user