Decide guest grants explicitly instead of by permission name

guest_grantable_permissions() opened any permission whose name ended
'.view' or '.read' to the guest role, on the reasoning that reading is
the safe default. That is true of a permission gating a list of asset
numbers and false of one gating bytes, and a suffix cannot tell them
apart. Twenty-four of eighty-five qualified and none had been decided:

  applications.view  reached the SYSTEM-context PowerShell bodies and
                     the installer binaries pushed to bays
  backups.view       returned the full payload that the separate
                     backups.download permission exists to protect
  assets.view        gated a global search that queries the staff
                     directory, USB history and network inventory with
                     no per-domain check of its own
  audit.view         usernames, addresses, the before/after of every
  settings.view      change, and every setting row - both admin
                     category, opened by naming alone
  users.view         gates nothing at all; list_users is
                     require_role('admin')

Every entry is now declared, by CORE_GUEST_GRANTABLE or by the plugin
that owns the permission, having read what its routes actually return.
Adding one is a code change, which is the point: it puts the decision
in a diff rather than in a suffix.

The two routes serving script bodies and installer packages move to a
new applications.download permission, which is not grantable, so a site
can still open the catalogue metadata to its floor without the bytes
going with it.

The declared set preserves the grants this site had already chosen:
applications.view, computers.view, endpoints.view, reports.view,
tools.secrets, warranty.view.

Two known gaps are recorded on the hooks that expose them rather than
silently carried: the computer serializer publishes every column plus
resolved rdp:// and vnc:// links, and the per-asset endpoint panels are
keyed on a small integer so walking ids reconstructs much of the roster
that endpoints.fleet exists to protect. Neither is widened here.

A shipped test was granting guests audit.view and passing.

Scope: product
(cherry picked from commit 347402896c)
This commit is contained in:
cproudlock
2026-08-31 07:01:52 -04:00
parent 5f27f81e88
commit 85e8a7b877
10 changed files with 192 additions and 41 deletions

View File

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

View File

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

View File

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

View File

@@ -46,6 +46,12 @@ class Permission(db.Model):
('assets.delete', 'Delete assets', 'assets'),
# Applications
('applications.view', 'View applications', 'applications'),
# Deliberately NOT '.view': reading the catalogue and pulling the bytes
# are different acts. A script body is SYSTEM-context PowerShell and an
# installer is a vendor binary, so the routes that serve them are gated
# here rather than on the metadata permission a site may hand to guests.
('applications.download',
'Download application installers and script bodies', 'applications'),
('applications.create', 'Create applications', 'applications'),
('applications.edit', 'Edit applications', 'applications'),
('applications.delete', 'Delete applications', 'applications'),
@@ -152,28 +158,58 @@ def full_permission_catalog(include_disabled=False):
return catalog
# Core permissions a site MAY grant the guest role. Declared, not derived.
# Each one has been read against what its routes actually return.
#
# Deliberately absent, with the reason, so nobody re-adds them by reflex:
# assets.view - gates the global search, which queries the staff directory
# and the USB, notification and network domains with no
# per-domain check of its own
# settings.view - every setting row, and secret masking is a substring test
# audit.view - usernames, addresses, and the before/after of every change
# users.view - gates nothing; list_users is require_role('admin')
CORE_GUEST_GRANTABLE = {
# Aggregate counts only; no row-level data reaches the caller.
'reports.view',
# Catalogue metadata. Safe only because the bytes moved to
# applications.download, which is not grantable.
'applications.view',
}
def guest_grantable_permissions():
"""Which permissions the guest role may be granted.
Reading is the safe default and covers almost everything a site wants to
open up: any `.view` or `.read` permission may be handed to guests.
Every entry is DECLARED, by core below or by a plugin through
get_guest_grantable_permissions(). Nothing qualifies by being spelled a
particular way.
A write is different - it lets somebody with no account change this
database, and the audit trail can then say when and from where but never
who. So a write has to be BLESSED by whoever owns it, through the plugin's
get_guest_grantable_permissions(). Secure Send is the case that exists:
creating a one-view link for somebody is the whole point of the page, and
the page has always been usable by people with no account here.
It used to: any name ending `.view` or `.read` was grantable, on the
reasoning that reading is the safe default. That is true of a permission
that gates a list of asset numbers and false of one that gates bytes, and
the suffix cannot tell them apart. `applications.view` reached the
SYSTEM-context PowerShell bodies and the installer binaries;
`backups.view` returned the full payload the separate backups.download
permission exists to protect; `assets.view` gated a global search that
queries the staff directory. None of those were decided. They were named.
Everything else is refused at the door rather than warned about, because a
checkbox that quietly makes deleting an asset anonymous is not a warning
anybody reads twice.
So the rule is inverted: a permission is grantable when somebody wrote it
down, having looked at what it actually serves. Adding one is a code
change, which is the point - it puts the decision in a diff rather than in
a suffix.
Naming one here does not open it. It makes the checkbox available in the
role grid; a site still has to tick it.
The bar, for a read: could an anonymous visitor hold this, forever, and
would the site be content with everything it returns? For a write, add
that the act can never be attributed to anybody.
"""
from flask import current_app, has_app_context
catalog = full_permission_catalog()
grantable = {name for name, _, _ in catalog
if name.endswith('.view') or name.endswith('.read')}
known = {name for name, _, _ in catalog}
grantable = {name for name in CORE_GUEST_GRANTABLE if name in known}
if not has_app_context():
return grantable
@@ -181,7 +217,6 @@ def guest_grantable_permissions():
if not pm:
return grantable
known = {name for name, _, _ in catalog}
for name, plugin in pm.get_all_plugins().items():
if not pm.registry.is_enabled(name):
continue

View File

@@ -107,17 +107,24 @@ class BasePlugin(ABC):
def get_guest_grantable_permissions(self) -> List[str]:
"""Permissions this plugin allows a site to grant to the GUEST role.
A guest is anyone with no account. Any `.view` or `.read` permission is
grantable without asking a plugin - reading is the safe default. This
hook is for the exception: a WRITE that is genuinely meant to be usable
by somebody who never signs in.
A guest is anyone with no account. EVERY permission of this plugin's
that a site may grant one has to be named here, reads included.
Reads used to be automatic: anything ending `.view` or `.read` was
grantable. That treats a name as a decision, and it went wrong in the
obvious way - permissions that gated installer bytes, backup payloads
and a search across the staff directory were all anonymous-grantable
because of how they were spelled. So the plugin that owns a permission
now says, having looked at what its routes return.
Naming one here does not open it. It makes the checkbox available in
the role grid; a site still has to tick it. Return [] (the default) and
none of this plugin's writes can ever be made anonymous.
nothing of this plugin's can be made anonymous.
The bar: could this action be attributed to nobody, forever, and still
be defensible in the audit log? If not, leave it out.
The bar for a read: could an anonymous visitor hold this, forever, and
would the site be content with everything the routes return - every
column of every serializer, not just the ones the UI happens to show?
For a write, add that the act can never be attributed to anybody.
"""
return []