Inverting the grantable rule from the .view suffix to explicit declaration dropped every permission that had qualified by how it was spelled. Two of them were shipped guest features with grants already in the field. guides.view: the nav item and list_guides both reason in comments about a signed-out reader holding it, and the route is written around one - drafts are filtered out, search matches the published revision so draft wording cannot leak through a hit, and the author branches cannot open for a caller with no user id. tools.view: without it a guest holding tools.secrets has permission to use Secure Send and no way to reach the page, because tools.view is what puts the entry in the nav. The tools themselves run in the browser and touch no data. The failure was worse than a missing checkbox. update_role refuses the WHOLE request when any submitted permission is undeclared, and the role grid resubmits what the role already holds, so a site that granted either of these under the old rule could no longer save the guest role at all - including to tick assets.view, which is the grant the shop floor needs to look up an asset number without signing in. The test asserts both stay grantable, so narrowing the set again means looking at what is already granted in the field rather than only at what the catalogue declares. Scope: product
157 lines
6.5 KiB
Python
157 lines
6.5 KiB
Python
"""Tech Tools plugin.
|
|
|
|
A section for small technician utilities. MOST tools here run entirely in the
|
|
browser - no API, no tables, no state - which is deliberate: these are the
|
|
things someone reaches for at a bench with no network guarantee, and an
|
|
air-gapped site gets them for free.
|
|
|
|
SECURE SEND IS THE EXCEPTION, and it is why this plugin now has a blueprint, a
|
|
table and a migration chain where it once had none. A link that expires and dies
|
|
on first read cannot be browser-only: something has to hold the ciphertext until
|
|
it is claimed, and forget it afterwards. The secret itself is still encrypted in
|
|
the browser - the key travels in the URL fragment and never reaches this server -
|
|
so what is stored is ciphertext nobody on this side can read.
|
|
|
|
The plugin also exists so the section can carry a nav entry and be included in or
|
|
left out of a per-site build (ADR-013).
|
|
"""
|
|
|
|
import logging
|
|
from typing import List, Optional, Type
|
|
|
|
from flask import Blueprint, Flask
|
|
|
|
from shopdb.plugins.base import BasePlugin, PluginMeta
|
|
|
|
from .models import SecureSecret
|
|
from .api import tools_bp
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ToolsPlugin(BasePlugin):
|
|
"""Registers the Tech Tools section and the one tool that needs a server."""
|
|
|
|
@property
|
|
def meta(self) -> PluginMeta:
|
|
return PluginMeta(
|
|
name='tools',
|
|
version='1.0.0',
|
|
description=('Tech Tools: technician utilities, mostly browser-side '
|
|
'(barcode/QR labels) plus secure send for handing '
|
|
'somebody a credential by one-view link'),
|
|
author='ShopDB Team',
|
|
dependencies=[],
|
|
core_version='>=0.16.0,<1.0.0',
|
|
api_prefix='/api/tools',
|
|
)
|
|
|
|
def get_blueprint(self) -> Optional[Blueprint]:
|
|
# Secure send only. Every other tool is still browser-side.
|
|
return tools_bp
|
|
|
|
def get_models(self) -> List[Type]:
|
|
return [SecureSecret]
|
|
|
|
def get_permissions(self) -> List:
|
|
"""Who may CREATE a secret, when guests are not allowed.
|
|
|
|
The default access model: signed in, and holding this. Reading needs no
|
|
permission at all - the recipient usually has no account here and the
|
|
unguessable token is the authorisation.
|
|
|
|
A site that turns on securesend_allow_guests bypasses this for people
|
|
with no account; it still applies to anyone who IS signed in, so a
|
|
member without it cannot create one while a guest can. That is
|
|
deliberate: opening the door to guests is a decision about the door, not
|
|
about revoking what a role does or does not hold.
|
|
"""
|
|
return [
|
|
# Reaching the tools page at all. The tools themselves run in the
|
|
# browser and touch no data, so this is about whether the entry
|
|
# appears - which is exactly the question a site answers when it
|
|
# decides whether people without an account may use them.
|
|
('tools.view', 'Use the browser tools (codes, conversions)', 'tools'),
|
|
('tools.secrets', 'Create secure send links', 'tools'),
|
|
]
|
|
|
|
def get_guest_grantable_permissions(self) -> List[str]:
|
|
"""Creating a secure send link is meant to work without an account.
|
|
|
|
Somebody standing at a bench needs to send a password to a vendor. The
|
|
recipient never had an account, and often neither does the sender - the
|
|
unguessable token is the authorisation, and the link is one-view.
|
|
|
|
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', 'tools.view']
|
|
|
|
def get_settings_defaults(self) -> List[dict]:
|
|
"""The one switch this plugin owns.
|
|
|
|
Public so the create page can read it BEFORE login and show a guest the
|
|
form rather than a sign-in wall. It says nothing sensitive: whether the
|
|
door is open is visible to anyone who tries the door.
|
|
"""
|
|
return [
|
|
{
|
|
'key': 'securesend_allow_guests',
|
|
'value': 'false',
|
|
'valuetype': 'boolean',
|
|
'category': 'tools',
|
|
'public': True,
|
|
'description': ('Let people with no account create secure send '
|
|
'links. Off by default: a guest secret has no '
|
|
'creator, so the audit trail can say when and '
|
|
'from where but never who. Receiving a secret '
|
|
'never needs an account either way.'),
|
|
},
|
|
]
|
|
|
|
def get_settings_cards(self) -> List[dict]:
|
|
"""A page for the one switch this plugin owns (ADR-010).
|
|
|
|
get_settings_defaults SEEDS the row; this is what gives somebody a place
|
|
to change it. Declaring only the first left the setting in the database
|
|
with no way to reach it, which is indistinguishable from the feature not
|
|
existing.
|
|
"""
|
|
return [
|
|
{
|
|
'group': 'Tools',
|
|
'to': '/settings/secure-send',
|
|
# From the settings iconMap; an unknown key falls back to a
|
|
# puzzle piece, which reads as "nobody chose this".
|
|
'icon': 'wrench',
|
|
'title': 'Secure Send',
|
|
'description': 'Whether people without an account can create '
|
|
'one-view password links',
|
|
'position': 60,
|
|
},
|
|
]
|
|
|
|
def get_navigation_items(self) -> List[dict]:
|
|
return [
|
|
{
|
|
'name': 'Tech Tools',
|
|
'icon': 'wrench',
|
|
'route': '/tools',
|
|
'position': 47,
|
|
'section': 'fleet',
|
|
# Either right reaches the page: somebody granted only Secure
|
|
# Send still needs the entry that leads to it.
|
|
'permission': ['tools.view', 'tools.secrets'],
|
|
},
|
|
]
|
|
|
|
def on_install(self, app: Flask) -> None:
|
|
logger.info('Tools plugin installed')
|