diff --git a/plugins/guides/plugin.py b/plugins/guides/plugin.py index 0f941003..864f3c4c 100644 --- a/plugins/guides/plugin.py +++ b/plugins/guides/plugin.py @@ -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 diff --git a/plugins/tools/plugin.py b/plugins/tools/plugin.py index 463a82eb..0dd105af 100644 --- a/plugins/tools/plugin.py +++ b/plugins/tools/plugin.py @@ -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. diff --git a/tests/test_core/test_guest_role.py b/tests/test_core/test_guest_role.py index 3a37d511..9381a436 100644 --- a/tests/test_core/test_guest_role.py +++ b/tests/test_core/test_guest_role.py @@ -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,4 @@ 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 +