Let a guest actually use search: declare the shipped guest grants, and draw the box for whoever can search

This commit is contained in:
2026-08-31 09:56:49 -04:00
parent 0e3d5a6226
commit 64c51ce805
8 changed files with 170 additions and 4 deletions

View File

@@ -2447,6 +2447,34 @@
}
}
},
"/api/dashboard/capabilities": {
"get": {
"tags": [
"core-platform"
],
"summary": "Booleans describing what THIS caller may reach, so the app chrome can avoid drawing controls that would only answer...",
"description": "Booleans describing what THIS caller may reach, so the app chrome can avoid drawing controls that would only answer 401; cansearch reports whether the caller holds assets.view, which a signed-out visitor does when the guest role has been granted it\n\n**Auth:** none\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/dashboard/capabilities\n```",
"security": [],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
}
}
}
},
"/api/dashboard/widgets": {
"get": {
"tags": [

View File

@@ -536,6 +536,9 @@ export const dashboardApi = {
},
navigation() {
return api.get('/dashboard/navigation')
},
capabilities() {
return api.get('/dashboard/capabilities')
}
}

View File

@@ -11,9 +11,12 @@
</div>
</div>
<!-- Global search is authenticated; offering the box to a signed-out
reader gives them a control that can only answer 401. -->
<div v-if="authStore.isAuthenticated" class="sidebar-search">
<!-- Drawn only for a caller who can actually search. The server
answers that, because a guest has no user object here and
hasPermission would say no for the very visitor the guest grant
exists to serve. Members who lack assets.view lose a box that only
ever answered 401 to them. -->
<div v-if="cansearch" class="sidebar-search">
<input
v-model="searchQuery"
type="text"
@@ -145,6 +148,10 @@ const routeViewKey = computed(() =>
route.path.startsWith('/settings') ? '/settings' : route.path
)
const searchQuery = ref('')
// Starts false so the box is never drawn before the server has said the
// caller can use it. A flash of a control that then vanishes reads worse than
// one that arrives a moment late.
const cansearch = ref(false)
const navItems = ref([])
const activeNotifications = ref([])
const facilityName = ref('ShopDB')
@@ -289,6 +296,15 @@ onMounted(async () => {
navItems.value = buildNavItems(defaultNav)
}
try {
const caps = await dashboardApi.capabilities()
cansearch.value = !!caps.data.data?.cansearch
} catch (err) {
// Leave the box hidden. Guessing from isAuthenticated here would put it
// back in front of members who cannot search, which is the thing this
// replaced.
}
try {
const resp = await notificationsApi.active()
activeNotifications.value = resp.data.data?.notifications || []

View File

@@ -74,6 +74,25 @@ class GuidesPlugin(BasePlugin):
('guides.delete', 'Delete guides', 'guides'),
]
def get_guest_grantable_permissions(self) -> List[str]:
"""A procedure is for whoever is standing at the machine.
Guides are written to be followed on the floor, by people who often
have no account and no reason to get one. The nav item and the API
route both already reason about a signed-out reader holding this, so
the grant was intended from the start; it qualified under the old
.view suffix rule and is written down here now that a grant has to be
declared.
What it reaches is bounded by the routes, not by this: list_guides
returns published guides only, matching search against the PUBLISHED
revision so draft wording cannot leak through a hit, and a guest can
never take the author branches because a guest has no user id. Guide
history stays with the editors. Publishing, editing and deleting are
separate permissions and none of them is grantable.
"""
return ['guides.view']
def get_navigation_items(self) -> List[Dict]:
# Position 8, directly under Slides (7), rather than down in the
# Information section with the reference lists. A guide is something you

View File

@@ -85,8 +85,14 @@ class ToolsPlugin(BasePlugin):
A guest-created link records no creator. The audit trail says when and
from where, and never who. That is the cost, and it is why this is the
only write in the product offered to guests at all.
tools.view rides along because tools.secrets without it is a page a
guest holds permission to use and no way to reach: it gates the nav
entry, and the browser tools behind it (codes, conversions) run client
side and touch no data at all. Declared explicitly rather than inferred
from the .view suffix, which is what the grantable rule now asks for.
"""
return ['tools.secrets']
return ['tools.secrets', 'tools.view']
def get_settings_defaults(self) -> List[dict]:
"""The one switch this plugin owns.

View File

@@ -252,6 +252,38 @@ def get_navigation():
return success_response(all_items)
@dashboard_bp.route('/capabilities', methods=['GET'])
def get_capabilities():
"""What THIS caller can reach, for chrome that would otherwise only 401.
The sidebar search box was gated on being signed in, because a box that
can only answer 401 is worse than no box. That stopped being the right
test once the guest role could be granted assets.view: a signed-out
visitor at a machine may now search for the asset number in front of them,
and the box stayed hidden from exactly the person it was re-opened for.
The client cannot answer this itself. Its hasPermission getter reads the
signed-in user's permission list, and a guest has no user at all, so
asking it about a guest returns false for everyone. The navigation route
above has the same problem and solves it the same way: the server knows
what the guest role holds, so the server decides.
Public, and deliberately so - it is the pre-login call that decides
whether the box is drawn. It reports only booleans about the caller, never
what the guest role holds in general, so it says nothing to an anonymous
reader that the rendered page does not already.
"""
from flask_jwt_extended import verify_jwt_in_request
from shopdb.utils.authz import caller_holds
verify_jwt_in_request(optional=True)
return success_response({
# Global search is gated on assets.view, and each domain behind it now
# gates on its own permission, so holding this reaches assets alone.
'cansearch': caller_holds('assets.view'),
})
@dashboard_bp.route('/widgets', methods=['GET'])
@require_permission('assets.view')
def get_widgets():

View File

@@ -125,6 +125,28 @@ def test_a_view_permission_is_not_grantable_merely_for_being_a_view(app):
assert name not in grantable, name
def test_the_guest_features_that_already_shipped_stay_grantable(app):
"""Both were granted on a live site before a grant had to be declared.
Inverting the rule to explicit declaration dropped every permission that
had qualified by its .view suffix, including two the product ships guest
flows for: guides, whose nav item and list route both reason about a
signed-out reader, and the tools page, without which a guest holding
tools.secrets cannot reach Secure Send at all.
A site that granted them under the old rule was then unable to save the
guest role AT ALL - the API refuses the whole request when any submitted
permission is undeclared, and the grid resubmits what the role already
holds. That deadlock is what this guards against, so anyone narrowing the
grantable set again has to look at what is already granted in the field.
"""
from shopdb.core.models import guest_grantable_permissions
with app.app_context():
grantable = guest_grantable_permissions()
assert 'guides.view' in grantable
assert 'tools.view' in grantable
def test_the_bytes_behind_applications_are_not_grantable(app):
"""applications.view is grantable only because the bytes moved off it."""
from shopdb.core.models import guest_grantable_permissions
@@ -232,3 +254,39 @@ def test_a_bad_token_is_still_refused(client, db, guestrole):
response = client.get('/api/auditlogs',
headers={'Authorization': 'Bearer not.a.token'})
assert response.status_code == 422
# ---------------------------------------------------------------------------
# What the chrome is allowed to draw
# ---------------------------------------------------------------------------
def test_a_guest_is_not_offered_search_before_the_grant(client, guestrole):
"""The box is hidden until somebody opens the door, not by default."""
body = client.get('/api/dashboard/capabilities').get_json()['data']
assert body['cansearch'] is False
def test_granting_a_guest_assets_view_offers_them_search(client, db, guestrole):
"""The point of the whole exercise: a signed-out visitor standing at a
machine gets the box, so they can look up the number in front of them."""
grant(db, guestrole, 'assets.view')
body = client.get('/api/dashboard/capabilities').get_json()['data']
assert body['cansearch'] is True
def test_a_member_without_assets_view_is_not_offered_search(client, db,
member_headers):
"""It used to be drawn for anyone signed in, and answered 401 to the ones
who could not use it."""
body = client.get('/api/dashboard/capabilities',
headers=member_headers).get_json()['data']
assert body['cansearch'] is False
def test_capabilities_does_not_report_what_the_guest_role_holds(client, db,
guestrole):
"""It answers about the CALLER only. A signed-out reader learns whether
their own box is drawn, not the shape of the site's guest policy."""
grant(db, guestrole, 'assets.view')
body = client.get('/api/dashboard/capabilities').get_json()['data']
assert set(body) == {'cansearch'}

View File

@@ -28,6 +28,10 @@ PUBLIC_ENDPOINTS = {
'plugins.list_enabled_plugins',
'dashboard.get_navigation', # filtered per caller; a guest sees only
# what the guest role can reach
'dashboard.get_capabilities', # booleans about the CALLER, used to decide
# whether the search box is drawn at all;
# it must answer a signed-out visitor or
# the guest grant cannot reach the chrome
'dashboard.health_check', # monitoring has no account
# Settings: the handler serves an unauthenticated caller only the public