Whether somebody with no account may see something was being answered one feature at a time: a securesend_allow_guests setting, an enable flag per kiosk, and very nearly a third for guides - each with its own settings page, its own vocabulary and its own hand-written branch inside a route. Every one of them answered a permissions question, and this app already administers permissions per role. So a caller with no token is not nobody: they hold the seeded `guest` role. require_permission consults it when there is no identity, which is the entire mechanism. The role is seeded holding NOTHING, so an upgrade opens no door; a site opens one by ticking a box in the same grid it uses for every other role, and closes it the same way. Revocation is read on every request rather than cached, because taking guest access away is what somebody does the moment they realise what they opened, and it has to bite immediately. The redundant @jwt_required() above require_permission is gone from 184 routes. It was belt-and-braces before; now it is actively wrong, because it refuses a request with no token before the permission check can have an opinion, so a grant would appear to do nothing. What a guest may hold is bounded. Any .view or .read permission is grantable. A WRITE has to be blessed by whoever owns it, through the new get_guest_grantable_permissions hook (contract 0.21.0), and Secure Send is the only case that exists: sending a password to a vendor is the one write this product means to offer somebody who never signs in. Everything else is refused by the API and greyed out in the grid rather than warned about - a checkbox that quietly makes deleting an asset anonymous is not a warning anybody reads twice. A guest write records no creator: the trail says when and from where, never who, and that is the reason for the bar. GE-Enforce and Slides gained read permissions so they can be opened to guests without handing out the manage right. Their read routes accept either through require_any_permission, so nothing an existing manager could reach has moved. The navigation endpoint now returns only what the caller can actually reach. Without that a signed-out visitor gets a sidebar of the whole app where every link bounces off the login page, which reads as a broken site rather than as a closed door. A nav item may name several permissions; holding any one is enough, which is what GE-Enforce and Slides need now that a viewer and a manager hold different strings. Two things this found on the way: current_user is a LocalProxy, so `current_user is None` is False for a request with nobody behind it - harmless under a required JWT, an AttributeError the moment verification became optional. And an image pasted into a guide before its first save was never attached to it, so it was visible to editors and invisible to every reader; saving now attaches it, and `flask guides fix-images` repairs what is stored. The guides guest setting written earlier today is gone. Granting guides.view to the guest role replaces it, which is the point.
143 lines
5.5 KiB
Python
143 lines
5.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 [
|
|
('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.
|
|
"""
|
|
return ['tools.secrets']
|
|
|
|
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',
|
|
},
|
|
]
|
|
|
|
def on_install(self, app: Flask) -> None:
|
|
logger.info('Tools plugin installed')
|