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": {
|
"/api/dashboard/widgets": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
@@ -536,6 +536,9 @@ export const dashboardApi = {
|
|||||||
},
|
},
|
||||||
navigation() {
|
navigation() {
|
||||||
return api.get('/dashboard/navigation')
|
return api.get('/dashboard/navigation')
|
||||||
|
},
|
||||||
|
capabilities() {
|
||||||
|
return api.get('/dashboard/capabilities')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Global search is authenticated; offering the box to a signed-out
|
<!-- Drawn only for a caller who can actually search. The server
|
||||||
reader gives them a control that can only answer 401. -->
|
answers that, because a guest has no user object here and
|
||||||
<div v-if="authStore.isAuthenticated" class="sidebar-search">
|
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
|
<input
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -145,6 +148,10 @@ const routeViewKey = computed(() =>
|
|||||||
route.path.startsWith('/settings') ? '/settings' : route.path
|
route.path.startsWith('/settings') ? '/settings' : route.path
|
||||||
)
|
)
|
||||||
const searchQuery = ref('')
|
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 navItems = ref([])
|
||||||
const activeNotifications = ref([])
|
const activeNotifications = ref([])
|
||||||
const facilityName = ref('ShopDB')
|
const facilityName = ref('ShopDB')
|
||||||
@@ -289,6 +296,15 @@ onMounted(async () => {
|
|||||||
navItems.value = buildNavItems(defaultNav)
|
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 {
|
try {
|
||||||
const resp = await notificationsApi.active()
|
const resp = await notificationsApi.active()
|
||||||
activeNotifications.value = resp.data.data?.notifications || []
|
activeNotifications.value = resp.data.data?.notifications || []
|
||||||
|
|||||||
@@ -74,6 +74,25 @@ class GuidesPlugin(BasePlugin):
|
|||||||
('guides.delete', 'Delete guides', 'guides'),
|
('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]:
|
def get_navigation_items(self) -> List[Dict]:
|
||||||
# Position 8, directly under Slides (7), rather than down in the
|
# Position 8, directly under Slides (7), rather than down in the
|
||||||
# Information section with the reference lists. A guide is something you
|
# 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
|
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
|
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.
|
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]:
|
def get_settings_defaults(self) -> List[dict]:
|
||||||
"""The one switch this plugin owns.
|
"""The one switch this plugin owns.
|
||||||
|
|||||||
@@ -252,6 +252,38 @@ def get_navigation():
|
|||||||
return success_response(all_items)
|
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'])
|
@dashboard_bp.route('/widgets', methods=['GET'])
|
||||||
@require_permission('assets.view')
|
@require_permission('assets.view')
|
||||||
def get_widgets():
|
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
|
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):
|
def test_the_bytes_behind_applications_are_not_grantable(app):
|
||||||
"""applications.view is grantable only because the bytes moved off it."""
|
"""applications.view is grantable only because the bytes moved off it."""
|
||||||
from shopdb.core.models import guest_grantable_permissions
|
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',
|
response = client.get('/api/auditlogs',
|
||||||
headers={'Authorization': 'Bearer not.a.token'})
|
headers={'Authorization': 'Bearer not.a.token'})
|
||||||
assert response.status_code == 422
|
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',
|
'plugins.list_enabled_plugins',
|
||||||
'dashboard.get_navigation', # filtered per caller; a guest sees only
|
'dashboard.get_navigation', # filtered per caller; a guest sees only
|
||||||
# what the guest role can reach
|
# 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
|
'dashboard.health_check', # monitoring has no account
|
||||||
|
|
||||||
# Settings: the handler serves an unauthenticated caller only the public
|
# Settings: the handler serves an unauthenticated caller only the public
|
||||||
|
|||||||
Reference in New Issue
Block a user