Offer the search box to whoever can actually search
The sidebar box was drawn for anyone signed in, on the reasoning that offering it to a signed-out reader gives them a control that can only answer 401. That stopped being the right test when the guest role became able to hold assets.view: the box was hidden from exactly the visitor the grant was reopened for, and a guest could reach search only by typing /search?q= by hand. The client cannot decide this. 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 answers no for everyone. get_navigation already had the problem and already solved it by letting the server decide, so capabilities does the same thing and reports what the CALLER holds. It reports booleans about the caller and never what the guest role holds in general, so it tells an anonymous reader nothing the rendered page does not. Public because it is the pre-login call that decides whether the box is drawn at all. Members who lack assets.view now lose a box that only ever 401'd for them. On a failed call the box stays hidden rather than falling back to the old signed-in test, which would put it back in front of those same members. Scope: product
This commit is contained in:
@@ -90,6 +90,6 @@ Manifest-less directories under `plugins/` are core frontend surface and always
|
||||
|
||||
## Size
|
||||
|
||||
- test functions defined: **1570** (parametrised cases collect higher)
|
||||
- documented API paths: **345** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`)
|
||||
- test functions defined: **1575** (parametrised cases collect higher)
|
||||
- documented API paths: **346** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`)
|
||||
|
||||
|
||||
@@ -423,6 +423,14 @@
|
||||
"params": "none",
|
||||
"example": "curl http://localhost:5001/api/dashboard/navigation"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/dashboard/capabilities",
|
||||
"purpose": "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",
|
||||
"auth": "none",
|
||||
"params": "none",
|
||||
"example": "curl http://localhost:5001/api/dashboard/capabilities"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/dashboard/widgets",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -536,6 +536,9 @@ export const dashboardApi = {
|
||||
},
|
||||
navigation() {
|
||||
return api.get('/dashboard/navigation')
|
||||
},
|
||||
capabilities() {
|
||||
return api.get('/dashboard/capabilities')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 || []
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -255,3 +255,38 @@ def test_a_bad_token_is_still_refused(client, db, guestrole):
|
||||
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'}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user