Files
shopdb-flask/plugins/tools/plugin.py
cproudlock 8750ba2e74
Some checks failed
CI / backend (push) Failing after 7m12s
CI / frontend (push) Has been cancelled
CI / migrations-mysql (push) Has been cancelled
CI / naming (push) Has been cancelled
Let a site open secure send to guests, and choose how many times a link opens
Two asks, and the first is a real security decision so it is a SETTING that
defaults to the safe answer rather than a code change somebody inherits.

securesend_allow_guests, OFF by default. With it off the model is unchanged: a
login plus tools.secrets. With it on, somebody with no account - at a shared bay
terminal, a contractor - can create a link too.

The cost is named in the setting's own description and pinned by a test: a guest
secret has NO CREATOR, so the audit trail can say when and from where but never
who. That is the whole reason it is a switch a person turns on deliberately.

IT FAILS CLOSED. A missing row, a half-migrated site, an unhappy database - the
answer is no. An error must never be the thing that opens an unauthenticated
write endpoint.

A SIGNED-IN USER STILL NEEDS THE PERMISSION even when guests are allowed, which
is surprising enough that a test pins it: opening the door to guests is a
decision about the door, not about revoking what a role holds.

Guest creation is rate limited per address, same shape as the login limiter. Not
a security boundary - a determined person has more addresses - but an open
endpoint should not be an easy place to park data or fill a disk.

VIEWS ARE NOW A NUMBER, 1 to 100, instead of a four-item dropdown. How many
people need a password is the sender's business: a shift handover is not one
colleague on a call. The floor is what matters, not the ceiling - unlimited
views would just be a password on a web page.

Also folded in, from the retention question: nothing removed a secret that
EXPIRED WITHOUT BEING OPENED. The recipient goes on holiday, the link is never
clicked, and the ciphertext sat there for good - not readable, but it made
"nothing lingers" untrue and grew the table in exactly the case where the
feature failed. Any create now sweeps expired rows and a late click tidies its
own, so it stays bounded without a scheduler an air-gapped site would never add.

Two gates caught mistakes on the way: current_user is a LocalProxy and is never
None, so `current_user is None` silently took the signed-in branch for a guest;
and cache was imported straight from shopdb.extensions when it is already on the
shopdb.api contract surface.

1836 tests green.
2026-08-21 15:53:30 -04:00

107 lines
4.0 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_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_navigation_items(self) -> List[dict]:
return [
{
'name': 'Tech Tools',
'icon': 'wrench',
'route': '/tools',
'position': 47,
},
]
def on_install(self, app: Flask) -> None:
logger.info('Tools plugin installed')