Let a guest actually use search: declare the shipped guest grants, and draw the box for whoever can search
This commit is contained in:
@@ -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 || []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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