Gate each search domain on its own permission

Global search is gated on assets.view, and that was the whole check.
Behind it the route queried twelve domains: applications, the knowledge
base, guides, the staff directory, assets, measuring tools, USB devices,
printed parts, custom fields, notifications, hostnames, and vendor and
model reference data, plus addresses and subnets for an IP-shaped query.
None of them consulted their own permission.

So anyone who could look up an asset number could also read the staff
directory out of the HR database, the USB checkout history and who holds
which drive, and the network inventory. The SEARCH_DOMAINS settings look
like they bound this but they are site feature toggles, not
authorization.

Each domain now answers only to a caller who holds its permission.
Assets, custom fields and vendor/model stay on assets.view because all
three emit asset rows routed to asset pages.

caller_holds rather than require_permission: refusing the whole request
because one domain is off-limits would make search useless to anyone
holding less than everything. A domain the caller cannot see contributes
nothing and the rest still answers.

This is what makes assets.view safe to grant the guest role again. It
left the grantable set in 3474028 because of the leak above, and that
took the shop floor's asset lookup with it - a signed-out visitor at a
machine could no longer search for the asset number in front of them.
Now the grant reaches assets and nothing else.

The first version of the leak test passed with the gates removed. The
staff directory is an external HR database and is unreachable under
test, so the domain silently returned nothing and the assertion proved
nothing. It patches _employee_rows now, and fails when the gate goes.

Scope: product
(cherry picked from commit 0a01ddcf25)
This commit is contained in:
cproudlock
2026-08-31 08:34:04 -04:00
parent dd52666d6b
commit 4d0baaff77
5 changed files with 160 additions and 23 deletions

View File

@@ -109,15 +109,19 @@ def test_a_declared_permission_is_grantable(app):
def test_a_view_permission_is_not_grantable_merely_for_being_a_view(app):
"""The suffix stopped deciding. These are all '.view' and none is declared.
assets.view gates a global search that reaches the staff directory;
settings.view every setting row; audit.view the before/after of every
settings.view gates every setting row; audit.view the before/after of every
change; users.view nothing at all. Each was anonymous-grantable purely
because of how it was spelled.
assets.view is deliberately NOT in this list. It was, while global search
answered twelve domains behind it; now that search gates each domain on its
own permission the grant reaches assets alone, and the floor needs it to
look an asset number up without signing in.
"""
from shopdb.core.models import guest_grantable_permissions
with app.app_context():
grantable = guest_grantable_permissions()
for name in ('assets.view', 'settings.view', 'audit.view', 'users.view'):
for name in ('settings.view', 'audit.view', 'users.view'):
assert name not in grantable, name
@@ -196,7 +200,7 @@ def test_the_role_list_says_what_a_guest_may_hold(client, auth_headers,
assert guest['isguest'] is True
assert 'reports.view' in guest['grantable']
assert 'assets.delete' not in guest['grantable']
assert 'assets.view' not in guest['grantable']
assert 'audit.view' not in guest['grantable']
other = next((r for r in rows if r['rolename'] != 'guest'), None)
if other is not None:

View File

@@ -0,0 +1,94 @@
"""Global search answers each domain only to a caller who holds ITS permission.
The route is gated on assets.view, and that used to be the entire check. One
permission then queried twelve domains, so anyone who could look up an asset
number also read out of the staff directory, the USB checkout history, the
notification log and the network inventory.
That is why assets.view could not be handed to guests, which cost the floor its
search. Gating each domain separately is what makes the grant safe again: it
reaches assets and nothing else.
"""
from unittest.mock import patch
import pytest
from shopdb.core.models import Permission, Role
def _grant_guest(db, *names):
guest = Role.guest()
guest.permissions = [p for p in Permission.query.filter(
Permission.name.in_(names)).all()]
db.session.commit()
@pytest.fixture
def guestrole(db):
"""The seeded guest role, holding nothing."""
Permission.seed()
Role.seedguest()
db.session.commit()
return Role.guest()
def _search(client, term='a'):
response = client.get('/api/search?q={}'.format(term))
assert response.status_code == 200, response.get_json()
return response.get_json()['data']
FAKE_EMPLOYEE = [{
'First_Name': 'Ada', 'Last_Name': 'Smith', 'SSO': 508123,
'Team': 'Machining', 'Role': 'Operator',
}]
def _with_employee_directory():
"""The HR directory is an external database and is unreachable in tests, so
the domain silently returned nothing and a leak test over it proved nothing.
Patched here so there is something real to leak."""
return patch('shopdb.core.api.search._employee_rows',
return_value=FAKE_EMPLOYEE)
def test_assets_view_alone_does_not_reach_the_staff_directory(client, db,
guestrole):
"""The finding this exists for: ?q=smith returned employee records to a
signed-out visitor holding only assets.view."""
_grant_guest(db, 'assets.view')
with _with_employee_directory():
data = _search(client, 'smith')
assert 'employee' not in {row['type'] for row in data['results']}
def test_employees_view_does_reach_it(client, db, guestrole):
"""The other direction, so the gate is not passing by refusing everything."""
_grant_guest(db, 'assets.view', 'employees.view')
with _with_employee_directory():
data = _search(client, 'smith')
employees = [row for row in data['results'] if row['type'] == 'employee']
assert len(employees) == 1
assert employees[0]['title'] == 'Ada Smith'
def test_search_still_answers_a_guest_with_assets_view(client, db, guestrole):
"""The regression that prompted this: guests lost search entirely.
assets.view left the grantable set because of the leak above, which took
the floor's asset lookup with it.
"""
_grant_guest(db, 'assets.view')
response = client.get('/api/search?q=press')
assert response.status_code == 200
def test_a_guest_with_nothing_granted_is_refused(client, db, guestrole):
_grant_guest(db)
assert client.get('/api/search?q=press').status_code in (401, 403)
def test_assets_view_is_grantable_again(app):
from shopdb.core.models import guest_grantable_permissions
with app.app_context():
assert 'assets.view' in guest_grantable_permissions()