Move secure send into Tech Tools, where somebody would look for it
9845867gave it its own plugin because Tech Tools is browser-only and this needs storage. That was the right reading of the code and the wrong answer for the person using it: Tech Tools is where you go for a utility, and a tool nobody can find is a tool nobody uses. So Tech Tools now has a blueprint, a table and a migration chain where it had none. Its docstring and manifest say why, because "every tool here runs in the browser" was load-bearing documentation and is no longer true. WHAT MOVED /api/securesend -> /api/tools/secrets securesend.create -> tools.secrets securesend0001baseline -> tools0001securesend PLUGIN_TABLE_OWNERS, EXPECTED_HEAD_REVISION, EXEMPT_ENDPOINTS, the universal profile and the api-inventory surface all follow it. THE OLD "NO BACKEND" TEST IS NARROWED, NOT DELETED. It asserted get_blueprint() is None, which is the sort of guard that gets removed when it becomes inconvenient. It now asserts the blueprint serves EXACTLY the three secret routes, with their methods - so a future tool that quietly grows an endpoint or a table has to edit this test, and therefore make the decision on purpose rather than by drift. Asserting methods rather than paths also pins the detail this design turns on: redeeming is POST-ONLY. Chat and mail clients prefetch GET urls to build previews, and a preview that could redeem would consume a one-view secret before its recipient opened the link. Previously that was a comment; now it fails a test. UNINSTALLING THE OLD PLUGIN COULD NOT BE DONE WITH THE PLUGIN TOOLING. `flask plugin uninstall securesend` loads a plugin before uninstalling it, so a plugin whose directory has been deleted cannot be removed by the command that manages plugins - the registry entry had to be edited by hand. Worth fixing separately; it is the same registry-versus-code divergence the device work hit from the other side. MIGRATING A CHAIN THAT HAS ALREADY RUN needs the old table and its version row dropped first, because the relocated chain has no stamp and tries to create a table that exists. Only relevant to anyone who installed securesend during the hour it existed - it did reach the publication repo in4242028- and the fix is DROP TABLE securesendsecrets plus DROP TABLE alembic_version_securesend before running plugin upgrade-all. 1829 tests green. Still backend only: there are no pages yet, so nothing here is reachable by a person.
This commit is contained in:
@@ -12,7 +12,6 @@
|
||||
"notifications",
|
||||
"printedparts",
|
||||
"printers",
|
||||
"securesend",
|
||||
"slides",
|
||||
"tools",
|
||||
"usb",
|
||||
|
||||
@@ -3261,31 +3261,31 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"surface": "plugin-securesend",
|
||||
"surface": "plugin-tools",
|
||||
"endpoints": [
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/securesend",
|
||||
"auth": "permission:securesend.create",
|
||||
"path": "/api/tools/secrets",
|
||||
"auth": "permission:tools.secrets",
|
||||
"params": "body: ciphertext (base64, max 4096 chars), iv, expiryhours (1-168, default 24), views (1-5, default 1)",
|
||||
"purpose": "Store an already-encrypted secret and return its one-view link token. The AES key lives in the URL fragment and never reaches the server, so what is held here is ciphertext nobody on this side can read - which is why SIZE is the only limit the server can enforce, and why this is for passwords and short text rather than documents",
|
||||
"example": "curl -X POST -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{\"ciphertext\":\"...\",\"iv\":\"...\"}' http://localhost:5001/api/securesend"
|
||||
"example": "curl -X POST -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{\"ciphertext\":\"...\",\"iv\":\"...\"}' http://localhost:5001/api/tools/secrets"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/securesend/<token>",
|
||||
"path": "/api/tools/secrets/<token>",
|
||||
"auth": "public",
|
||||
"params": "none",
|
||||
"purpose": "Redeem a secret: returns the ciphertext once and spends a view, deleting the row when the last view goes. POST rather than GET because chat and mail clients prefetch GET urls to build previews, and a preview must not consume a one-view secret. Missing, expired and spent answer identically so a guessed token learns nothing",
|
||||
"example": "curl -X POST http://localhost:5001/api/securesend/9f8e7d6c5b4a39281706f5e4d3c2b1a0"
|
||||
"example": "curl -X POST http://localhost:5001/api/tools/secrets/9f8e7d6c5b4a39281706f5e4d3c2b1a0"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/securesend/<token>",
|
||||
"path": "/api/tools/secrets/<token>",
|
||||
"auth": "public",
|
||||
"params": "none",
|
||||
"purpose": "Whether a link is still live, WITHOUT spending it. Lets the read page say \"a secret is waiting and opening it will destroy it\" instead of consuming the only view to find out, and gives link scanners something harmless to fetch",
|
||||
"example": "curl http://localhost:5001/api/securesend/9f8e7d6c5b4a39281706f5e4d3c2b1a0"
|
||||
"example": "curl http://localhost:5001/api/tools/secrets/9f8e7d6c5b4a39281706f5e4d3c2b1a0"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -172,7 +172,7 @@
|
||||
"name": "plugin-backups"
|
||||
},
|
||||
{
|
||||
"name": "plugin-securesend"
|
||||
"name": "plugin-tools"
|
||||
},
|
||||
{
|
||||
"name": "core-docs"
|
||||
@@ -18823,13 +18823,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/securesend": {
|
||||
"/api/tools/secrets": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"plugin-securesend"
|
||||
"plugin-tools"
|
||||
],
|
||||
"summary": "Store an already-encrypted secret and return its one-view link token. The AES key lives in the URL fragment and never...",
|
||||
"description": "Store an already-encrypted secret and return its one-view link token. The AES key lives in the URL fragment and never reaches the server, so what is held here is ciphertext nobody on this side can read - which is why SIZE is the only limit the server can enforce, and why this is for passwords and short text rather than documents\n\n**Auth:** permission:securesend.create\n\n**Params:** body: ciphertext (base64, max 4096 chars), iv, expiryhours (1-168, default 24), views (1-5, default 1)\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{\"ciphertext\":\"...\",\"iv\":\"...\"}' http://localhost:5001/api/securesend\n```",
|
||||
"description": "Store an already-encrypted secret and return its one-view link token. The AES key lives in the URL fragment and never reaches the server, so what is held here is ciphertext nobody on this side can read - which is why SIZE is the only limit the server can enforce, and why this is for passwords and short text rather than documents\n\n**Auth:** permission:tools.secrets\n\n**Params:** body: ciphertext (base64, max 4096 chars), iv, expiryhours (1-168, default 24), views (1-5, default 1)\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{\"ciphertext\":\"...\",\"iv\":\"...\"}' http://localhost:5001/api/tools/secrets\n```",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
@@ -18873,13 +18873,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/securesend/{token}": {
|
||||
"/api/tools/secrets/{token}": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"plugin-securesend"
|
||||
"plugin-tools"
|
||||
],
|
||||
"summary": "Redeem a secret: returns the ciphertext once and spends a view, deleting the row when the last view goes. POST rather...",
|
||||
"description": "Redeem a secret: returns the ciphertext once and spends a view, deleting the row when the last view goes. POST rather than GET because chat and mail clients prefetch GET urls to build previews, and a preview must not consume a one-view secret. Missing, expired and spent answer identically so a guessed token learns nothing\n\n**Auth:** public\n\n**Params:** none\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/securesend/9f8e7d6c5b4a39281706f5e4d3c2b1a0\n```",
|
||||
"description": "Redeem a secret: returns the ciphertext once and spends a view, deleting the row when the last view goes. POST rather than GET because chat and mail clients prefetch GET urls to build previews, and a preview must not consume a one-view secret. Missing, expired and spent answer identically so a guessed token learns nothing\n\n**Auth:** public\n\n**Params:** none\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/tools/secrets/9f8e7d6c5b4a39281706f5e4d3c2b1a0\n```",
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
@@ -18927,10 +18927,10 @@
|
||||
},
|
||||
"get": {
|
||||
"tags": [
|
||||
"plugin-securesend"
|
||||
"plugin-tools"
|
||||
],
|
||||
"summary": "Whether a link is still live, WITHOUT spending it. Lets the read page say \"a secret is waiting and opening it will...",
|
||||
"description": "Whether a link is still live, WITHOUT spending it. Lets the read page say \"a secret is waiting and opening it will destroy it\" instead of consuming the only view to find out, and gives link scanners something harmless to fetch\n\n**Auth:** public\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/securesend/9f8e7d6c5b4a39281706f5e4d3c2b1a0\n```",
|
||||
"description": "Whether a link is still live, WITHOUT spending it. Lets the read page say \"a secret is waiting and opening it will destroy it\" instead of consuming the only view to find out, and gives link scanners something harmless to fetch\n\n**Auth:** public\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/tools/secrets/9f8e7d6c5b4a39281706f5e4d3c2b1a0\n```",
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
# Securesend plugin
|
||||
|
||||
One-view expiring links for handing someone a credential. The secret is
|
||||
encrypted in the browser and the key never reaches this server.
|
||||
|
||||
Replaces the habit it exists to kill: pasting a password into a chat message,
|
||||
where it stays until somebody deletes the conversation.
|
||||
|
||||
## What it is not
|
||||
|
||||
**Not a document transfer.** Text only, with a hard size limit. See "The size
|
||||
limit is the whole enforcement" below - that is a deliberate scope, not a
|
||||
missing feature.
|
||||
|
||||
**Not a Tech Tools page.** Tools is browser-only by design (`get_blueprint` and
|
||||
`get_models` are both empty there); this needs storage, expiry and a view
|
||||
counter, so it is its own plugin.
|
||||
|
||||
**Not an asset.** A shared credential is a short-lived event, not something the
|
||||
site inventories, so there is no assetid and no asset type.
|
||||
|
||||
## Two doors
|
||||
|
||||
| Route | Auth | Why |
|
||||
|---|---|---|
|
||||
| `POST /api/securesend` | login + `securesend.create` | Anonymous creation would be an untraceable way to move data out of a system that otherwise records who did what |
|
||||
| `POST /api/securesend/<token>` | none | The recipient usually has no account here. The unguessable token IS the authorisation |
|
||||
| `GET /api/securesend/<token>` | none | Reports whether a link is still live. Spends nothing |
|
||||
|
||||
## Redeeming is a POST, and that is not an accident
|
||||
|
||||
Teams, Outlook and Defender Safe Links prefetch GET urls to build previews and
|
||||
scan for malware. If a GET redeemed the secret, the preview would consume it and
|
||||
the recipient would open the link to find it already used.
|
||||
|
||||
The GET route exists so the read page can say "a secret is waiting, and opening
|
||||
it will destroy it" without spending the only view to find out.
|
||||
|
||||
## The size limit is the whole enforcement
|
||||
|
||||
The AES key lives in the URL fragment, which browsers never transmit, so the
|
||||
server holds ciphertext it cannot read. It therefore cannot tell a password from
|
||||
a page of a drawing, and `MAX_CIPHERTEXT` is not a tuning knob - it is the only
|
||||
control a zero-knowledge design leaves on the server side.
|
||||
|
||||
Everything else (line counts, file signatures, refusing a pasted file) belongs in
|
||||
the browser, guides an honest person, and is bypassable by anyone willing to POST
|
||||
directly. Raising the limit quietly turns this into the document transfer it was
|
||||
scoped not to be.
|
||||
|
||||
## What the audit trail can and cannot say
|
||||
|
||||
It can say who created a secret, how big it was, when it was read and how often.
|
||||
It cannot say what was in it - by design. That is the right trade for a
|
||||
credential and the wrong one for a controlled document, which is the other reason
|
||||
files are out of scope.
|
||||
|
||||
## Not enumerable
|
||||
|
||||
The token is 128 bits of randomness rendered as hex, never a sequence. Missing,
|
||||
expired and spent all answer identically, so a guessed token learns nothing about
|
||||
whether it ever named anything.
|
||||
|
||||
## Before rolling it out
|
||||
|
||||
**Test that your link rewriter preserves URL fragments.** Create a secret, send
|
||||
the link to yourself in Teams, and click it from the message. If Safe Links
|
||||
strips the `#`, the recipient gets a link with no key and the secret is
|
||||
unrecoverable - the server cannot help, which is the point of the design. The
|
||||
fallback if that happens is passphrase mode, where the key is derived from a
|
||||
passphrase shared out of band.
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Securesend plugin package."""
|
||||
|
||||
from .plugin import SecuresendPlugin
|
||||
|
||||
__all__ = ['SecuresendPlugin']
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Securesend plugin API package."""
|
||||
|
||||
from .routes import securesend_bp
|
||||
|
||||
__all__ = ['securesend_bp']
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"name": "securesend",
|
||||
"version": "0.1.0",
|
||||
"description": "One-view expiring links for handing someone a credential, encrypted in the browser",
|
||||
"author": "",
|
||||
"dependencies": [],
|
||||
"core_version": ">=0.1.0,<1.0.0",
|
||||
"api_prefix": "/api/securesend"
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Securesend plugin models."""
|
||||
|
||||
from .securesend import SecureSecret
|
||||
|
||||
__all__ = ['SecureSecret']
|
||||
@@ -1,76 +0,0 @@
|
||||
"""Securesend plugin main class.
|
||||
|
||||
One-view expiring links for handing someone a credential, encrypted in the browser
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Type
|
||||
|
||||
from flask import Flask, Blueprint
|
||||
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
|
||||
from .models import SecureSecret
|
||||
from .api import securesend_bp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SecuresendPlugin(BasePlugin):
|
||||
"""Securesend plugin.
|
||||
|
||||
One-view expiring links for handing someone a credential, encrypted in the
|
||||
browser. The key travels in the URL fragment and never reaches the server,
|
||||
so what is stored here is ciphertext nobody on this side can read.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
manifest_path = Path(__file__).parent / 'manifest.json'
|
||||
with open(manifest_path) as f:
|
||||
self._manifest = json.load(f)
|
||||
|
||||
@property
|
||||
def meta(self) -> PluginMeta:
|
||||
return PluginMeta(
|
||||
name=self._manifest['name'],
|
||||
version=self._manifest['version'],
|
||||
description=self._manifest['description'],
|
||||
author=self._manifest.get('author', ''),
|
||||
dependencies=self._manifest.get('dependencies', []),
|
||||
core_version=self._manifest.get('core_version', '>=0.1.0'),
|
||||
api_prefix=self._manifest.get('api_prefix'),
|
||||
)
|
||||
|
||||
def get_blueprint(self) -> Optional[Blueprint]:
|
||||
return securesend_bp
|
||||
|
||||
def get_models(self) -> List[Type]:
|
||||
return [SecureSecret]
|
||||
|
||||
def get_permissions(self) -> List:
|
||||
"""One permission, on CREATION only.
|
||||
|
||||
There is deliberately no read permission: the recipient of a secret
|
||||
usually has no account here, and the unguessable token is what
|
||||
authorises the read. Adding a permission to that side would make the
|
||||
feature useless for the case it exists for - handing a credential to
|
||||
somebody while you are on a call with them.
|
||||
|
||||
No delete permission either. A secret is spent by being read or by
|
||||
expiring, and both remove the row; a hand-delete route would only add a
|
||||
way to destroy the audit trail of a secret that had not been read yet.
|
||||
"""
|
||||
return [
|
||||
('securesend.create', 'Create shared secret links', 'securesend'),
|
||||
]
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
logger.info(f'Securesend plugin initialized (v{self.meta.version})')
|
||||
|
||||
def on_install(self, app: Flask) -> None:
|
||||
# NO ASSET TYPE. The scaffold assumes a plugin extends Asset, and this
|
||||
# one does not: a shared credential is a short-lived event, not a thing
|
||||
# the site owns and inventories.
|
||||
logger.info('Securesend plugin installed')
|
||||
@@ -1,6 +0,0 @@
|
||||
"""Securesend plugin schemas (marshmallow).
|
||||
|
||||
Add schema classes here when you need request/response validation
|
||||
beyond the simple to_dict() output. The framework wires marshmallow
|
||||
into the response helpers; see docs/PLUGIN-HOOKS.md for details.
|
||||
"""
|
||||
@@ -1,30 +0,0 @@
|
||||
"""Securesend plugin smoke tests.
|
||||
|
||||
Asserts the plugin loads cleanly and satisfies the framework contract.
|
||||
Replace and extend with domain tests as you build the plugin out.
|
||||
"""
|
||||
|
||||
from plugins.securesend.plugin import SecuresendPlugin
|
||||
|
||||
|
||||
def test_securesend_plugin_meta_is_valid():
|
||||
"""SecuresendPlugin.meta returns a PluginMeta with the expected name."""
|
||||
plugin = SecuresendPlugin()
|
||||
assert plugin.meta.name == 'securesend'
|
||||
assert plugin.meta.api_prefix == '/api/securesend'
|
||||
|
||||
|
||||
def test_securesend_plugin_get_blueprint_returns_blueprint():
|
||||
"""get_blueprint returns a Flask Blueprint, not None."""
|
||||
from flask import Blueprint
|
||||
plugin = SecuresendPlugin()
|
||||
assert isinstance(plugin.get_blueprint(), Blueprint)
|
||||
|
||||
|
||||
def test_securesend_plugin_get_models_returns_a_model():
|
||||
"""get_models returns a list with at least one SQLAlchemy model."""
|
||||
plugin = SecuresendPlugin()
|
||||
models = plugin.get_models()
|
||||
assert len(models) >= 1
|
||||
for model in models:
|
||||
assert hasattr(model, '__tablename__')
|
||||
5
plugins/tools/api/__init__.py
Normal file
5
plugins/tools/api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Tech Tools API package."""
|
||||
|
||||
from .routes import tools_bp
|
||||
|
||||
__all__ = ['tools_bp']
|
||||
@@ -1,4 +1,8 @@
|
||||
"""Create a one-view link, and redeem it.
|
||||
"""Secure send: create a one-view link for a credential, and redeem it.
|
||||
|
||||
Lives in Tech Tools because that is where somebody looks for a utility. It is
|
||||
the one tool here that is NOT purely browser-side: a link that expires and dies
|
||||
on first read needs somewhere to keep the ciphertext until then.
|
||||
|
||||
TWO DIFFERENT DOORS, on purpose.
|
||||
|
||||
@@ -33,7 +37,7 @@ from shopdb.api import (
|
||||
|
||||
from ..models import SecureSecret
|
||||
|
||||
securesend_bp = Blueprint('securesend', __name__)
|
||||
tools_bp = Blueprint('tools', __name__)
|
||||
|
||||
# Base64 of an encrypted credential. A password is well under a hundred
|
||||
# characters; this is generous for a connection string and useless for a
|
||||
@@ -53,9 +57,9 @@ def _utcnow():
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
@securesend_bp.route('', methods=['POST'])
|
||||
@tools_bp.route('/secrets', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('securesend.create')
|
||||
@require_permission('tools.secrets')
|
||||
def create_secret():
|
||||
"""Store an already-encrypted secret and return its token."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
@@ -118,7 +122,7 @@ def create_secret():
|
||||
return success_response(secret.to_dict(), http_code=201)
|
||||
|
||||
|
||||
@securesend_bp.route('/<token>', methods=['POST'])
|
||||
@tools_bp.route('/secrets/<token>', methods=['POST'])
|
||||
def redeem_secret(token):
|
||||
"""Hand over the ciphertext once, then spend the view.
|
||||
|
||||
@@ -159,7 +163,7 @@ def redeem_secret(token):
|
||||
return success_response(payload)
|
||||
|
||||
|
||||
@securesend_bp.route('/<token>', methods=['GET'])
|
||||
@tools_bp.route('/secrets/<token>', methods=['GET'])
|
||||
def peek_secret(token):
|
||||
"""Whether a link is still live, WITHOUT spending it.
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"name": "tools",
|
||||
"version": "1.0.0",
|
||||
"description": "Tech Tools: shop-floor utilities that run entirely in the browser (barcode/QR label generator, with room for more)",
|
||||
"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",
|
||||
"provides": {
|
||||
"features": ["code_generator"]
|
||||
}
|
||||
"features": [
|
||||
"code_generator",
|
||||
"secure_send"
|
||||
]
|
||||
},
|
||||
"api_prefix": "/api/tools"
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"""Alembic environment for the securesend plugin migration chain.
|
||||
"""Alembic environment for the tools plugin migration chain.
|
||||
|
||||
Delegates to the shared runner in shopdb.plugins.alembic_template, which filters
|
||||
the metadata to this plugin's tables and drives Alembic against the per-plugin
|
||||
version table alembic_version_securesend. See ADR-008 for the ownership
|
||||
version table alembic_version_tools. See ADR-008 for the ownership
|
||||
model. Unlike the ten cutover plugins whose 0001 is a no-op anchor, this plugin
|
||||
is NEW: its 0001 baseline really CREATES its tables, because the core chain
|
||||
never built them.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ['PLUGIN_NAME'] = 'securesend'
|
||||
os.environ['PLUGIN_NAME'] = 'tools'
|
||||
|
||||
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""securesend plugin baseline.
|
||||
"""tools plugin baseline: the secure send table.
|
||||
|
||||
Creates the one table this plugin owns. Not an asset extension: a shared
|
||||
Creates the one table this plugin owns. Tech Tools was schemaless until secure
|
||||
send arrived: a link that expires and dies on first read has to keep the
|
||||
ciphertext somewhere until it is claimed. Not an asset extension: a shared
|
||||
credential is a short-lived event rather than a thing the site inventories, so
|
||||
there is no assetid and no asset type.
|
||||
|
||||
@@ -14,7 +16,7 @@ import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'securesend0001baseline'
|
||||
revision = 'tools0001securesend'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
10
plugins/tools/models/__init__.py
Normal file
10
plugins/tools/models/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""Tech Tools models.
|
||||
|
||||
Most tools run entirely in the browser and own nothing. Secure send is the
|
||||
exception: handing somebody a credential needs storage, an expiry and a view
|
||||
counter, so this plugin does carry one table.
|
||||
"""
|
||||
|
||||
from .securesend import SecureSecret
|
||||
|
||||
__all__ = ['SecureSecret']
|
||||
@@ -1,13 +1,19 @@
|
||||
"""Tech Tools plugin.
|
||||
|
||||
A section for small technician utilities. Every tool here runs entirely in the
|
||||
browser: no API, no tables, no state. That 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.
|
||||
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.
|
||||
|
||||
The plugin exists so the section can carry a nav entry and be included in or
|
||||
left out of a per-site build (ADR-013). The tools themselves are frontend-only,
|
||||
which is why get_blueprint and get_models are both empty.
|
||||
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
|
||||
@@ -17,30 +23,51 @@ 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. All tools are client-side."""
|
||||
"""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: browser-side technician utilities',
|
||||
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]:
|
||||
# No API surface. The tools never leave the browser.
|
||||
return None
|
||||
# Secure send only. Every other tool is still browser-side.
|
||||
return tools_bp
|
||||
|
||||
def get_models(self) -> List[Type]:
|
||||
# No tables. Nothing here is persisted.
|
||||
return []
|
||||
return [SecureSecret]
|
||||
|
||||
def get_permissions(self) -> List:
|
||||
"""One permission, on CREATING a secret only.
|
||||
|
||||
There is deliberately no read permission: the recipient usually has no
|
||||
account here, and the unguessable token is what authorises the read.
|
||||
Gating that side would make the tool useless for the case it exists for -
|
||||
handing somebody a credential while you are on a call with them.
|
||||
|
||||
No delete permission either. A secret is spent by being read or by
|
||||
expiring, and both remove the row; a hand-delete route would only add a
|
||||
way to destroy the record of a secret that had not been read yet.
|
||||
"""
|
||||
return [
|
||||
('tools.secrets', 'Create secure send links', 'tools'),
|
||||
]
|
||||
|
||||
def get_navigation_items(self) -> List[dict]:
|
||||
return [
|
||||
|
||||
@@ -67,8 +67,10 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
|
||||
'printeditemfiles'),
|
||||
'printers': ('printertypes', 'printers', 'modelsupplies', 'printerdrivers',
|
||||
'printersupplyalerts', 'printerobservedqueues'),
|
||||
'securesend': ('securesendsecrets',),
|
||||
'slides': ('tvslides',),
|
||||
# Tech Tools was schemaless until secure send: every other tool in it
|
||||
# runs in the browser and owns nothing.
|
||||
'tools': ('securesendsecrets',),
|
||||
'usb': ('usbdevicetypes', 'usbdevices', 'usbcheckouts'),
|
||||
'warranty': ('warranties', 'warrantyassets'),
|
||||
}
|
||||
|
||||
@@ -61,8 +61,8 @@ EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user',
|
||||
# rather than a GET because chat and mail clients prefetch
|
||||
# GET urls and a preview must not consume a one-view link.
|
||||
# CREATING one is permission-gated and IS swept.
|
||||
# Justification in plugins/securesend/README.md.
|
||||
'securesend.redeem_secret'}
|
||||
# Justification in plugins/tools/README.md.
|
||||
'tools.redeem_secret'}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
||||
@@ -72,8 +72,9 @@ EXPECTED_HEAD_REVISION['network'] = 'network0003prefix'
|
||||
# the per-transaction revision column.
|
||||
EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0004txnrev'
|
||||
# Built after the ADR-008 cutover, so its 0001 genuinely creates the table
|
||||
# rather than stamping an anchor.
|
||||
EXPECTED_HEAD_REVISION['securesend'] = 'securesend0001baseline'
|
||||
# rather than stamping an anchor. Tech Tools owns a table now because secure
|
||||
# send needs one; every other tool in that plugin is browser-side.
|
||||
EXPECTED_HEAD_REVISION['tools'] = 'tools0001securesend'
|
||||
# notifications indexes businessunitid, then adds the per-type grace window and
|
||||
# the shared board category.
|
||||
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0005boardorder'
|
||||
|
||||
@@ -27,11 +27,39 @@ def test_meta_matches_manifest():
|
||||
assert meta.core_version == manifest['core_version']
|
||||
|
||||
|
||||
def test_no_backend_surface():
|
||||
"""Client-side only: no blueprint to register, no tables to migrate."""
|
||||
def test_the_only_backend_surface_is_secure_send():
|
||||
"""Tech Tools used to have NO backend, and mostly still should.
|
||||
|
||||
This asserted `get_blueprint() is None` until secure send arrived: a link
|
||||
that expires and dies on first read cannot be browser-only, because
|
||||
something has to hold the ciphertext until it is claimed. The guard is kept
|
||||
rather than deleted, narrowed to the one exception - a future tool that
|
||||
quietly grows an endpoint or a table has to change this test, and therefore
|
||||
make the decision on purpose rather than by accident.
|
||||
"""
|
||||
from flask import Flask
|
||||
|
||||
from plugins.tools.models import SecureSecret
|
||||
|
||||
plugin = ToolsPlugin()
|
||||
assert plugin.get_blueprint() is None
|
||||
assert plugin.get_models() == []
|
||||
assert plugin.get_models() == [SecureSecret]
|
||||
|
||||
probe = Flask(__name__)
|
||||
probe.register_blueprint(plugin.get_blueprint(), url_prefix='/api/tools')
|
||||
served = sorted(
|
||||
(str(rule), sorted(rule.methods - {'HEAD', 'OPTIONS'}))
|
||||
for rule in probe.url_map.iter_rules()
|
||||
if not str(rule).startswith('/static'))
|
||||
|
||||
# Methods are asserted, not just paths: REDEEMING MUST BE POST-ONLY. Chat
|
||||
# and mail clients prefetch GET urls to build previews, and a preview that
|
||||
# could redeem would consume a one-view secret before its recipient ever
|
||||
# opened the link. The GET on the same path reports availability only.
|
||||
assert served == [
|
||||
('/api/tools/secrets', ['POST']),
|
||||
('/api/tools/secrets/<token>', ['GET']),
|
||||
('/api/tools/secrets/<token>', ['POST']),
|
||||
], served
|
||||
|
||||
|
||||
def test_navigation_item():
|
||||
|
||||
@@ -7,8 +7,8 @@ fail in ways nobody notices until somebody needs the password.
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.securesend.models import SecureSecret
|
||||
from plugins.securesend.api.routes import MAX_CIPHERTEXT
|
||||
from plugins.tools.models import SecureSecret
|
||||
from plugins.tools.api.routes import MAX_CIPHERTEXT
|
||||
|
||||
CIPHER = 'ZmFrZS1jaXBoZXJ0ZXh0LWZvci10ZXN0cw=='
|
||||
IV = 'MTIzNDU2Nzg5MDEy'
|
||||
@@ -17,7 +17,7 @@ IV = 'MTIzNDU2Nzg5MDEy'
|
||||
def _create(client, headers, **overrides):
|
||||
body = {'ciphertext': CIPHER, 'iv': IV}
|
||||
body.update(overrides)
|
||||
return client.post('/api/securesend', json=body, headers=headers)
|
||||
return client.post('/api/tools/secrets', json=body, headers=headers)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- creating
|
||||
@@ -25,7 +25,7 @@ def _create(client, headers, **overrides):
|
||||
def test_creating_requires_a_login(client):
|
||||
"""Anonymous creation would be an untraceable way to move data out of a
|
||||
system that otherwise records who did what."""
|
||||
resp = client.post('/api/securesend', json={'ciphertext': CIPHER, 'iv': IV})
|
||||
resp = client.post('/api/tools/secrets', json={'ciphertext': CIPHER, 'iv': IV})
|
||||
assert resp.status_code in (401, 403, 422)
|
||||
|
||||
|
||||
@@ -74,29 +74,29 @@ def test_anyone_can_read_it_without_logging_in(client, auth_headers):
|
||||
"""The recipient usually has no account here. The unguessable token is the
|
||||
authorisation."""
|
||||
token = _create(client, auth_headers).get_json()['data']['token']
|
||||
resp = client.post('/api/securesend/%s' % token)
|
||||
resp = client.post('/api/tools/secrets/%s' % token)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data']['ciphertext'] == CIPHER
|
||||
|
||||
|
||||
def test_the_second_read_finds_nothing(client, auth_headers):
|
||||
token = _create(client, auth_headers).get_json()['data']['token']
|
||||
assert client.post('/api/securesend/%s' % token).status_code == 200
|
||||
assert client.post('/api/securesend/%s' % token).status_code == 404
|
||||
assert client.post('/api/tools/secrets/%s' % token).status_code == 200
|
||||
assert client.post('/api/tools/secrets/%s' % token).status_code == 404
|
||||
|
||||
|
||||
def test_a_spent_secret_is_deleted_not_just_marked(client, db, auth_headers):
|
||||
"""A secret that has been read has no reason to keep its ciphertext."""
|
||||
token = _create(client, auth_headers).get_json()['data']['token']
|
||||
client.post('/api/securesend/%s' % token)
|
||||
client.post('/api/tools/secrets/%s' % token)
|
||||
assert SecureSecret.query.filter_by(token=token).first() is None
|
||||
|
||||
|
||||
def test_more_than_one_view_can_be_allowed(client, auth_headers):
|
||||
token = _create(client, auth_headers, views=2).get_json()['data']['token']
|
||||
assert client.post('/api/securesend/%s' % token).status_code == 200
|
||||
assert client.post('/api/securesend/%s' % token).status_code == 200
|
||||
assert client.post('/api/securesend/%s' % token).status_code == 404
|
||||
assert client.post('/api/tools/secrets/%s' % token).status_code == 200
|
||||
assert client.post('/api/tools/secrets/%s' % token).status_code == 200
|
||||
assert client.post('/api/tools/secrets/%s' % token).status_code == 404
|
||||
|
||||
|
||||
# ------------------------------------------------------- link previews (Teams)
|
||||
@@ -111,19 +111,19 @@ def test_a_get_does_not_spend_the_secret(client, auth_headers):
|
||||
"""
|
||||
token = _create(client, auth_headers).get_json()['data']['token']
|
||||
|
||||
peek = client.get('/api/securesend/%s' % token)
|
||||
peek = client.get('/api/tools/secrets/%s' % token)
|
||||
assert peek.status_code == 200
|
||||
assert peek.get_json()['data']['available'] is True
|
||||
assert 'ciphertext' not in peek.get_json()['data']
|
||||
|
||||
# Still there for the person it was meant for.
|
||||
assert client.post('/api/securesend/%s' % token).status_code == 200
|
||||
assert client.post('/api/tools/secrets/%s' % token).status_code == 200
|
||||
|
||||
|
||||
def test_peeking_at_a_spent_link_says_so_without_leaking(client, auth_headers):
|
||||
token = _create(client, auth_headers).get_json()['data']['token']
|
||||
client.post('/api/securesend/%s' % token)
|
||||
peek = client.get('/api/securesend/%s' % token)
|
||||
client.post('/api/tools/secrets/%s' % token)
|
||||
peek = client.get('/api/tools/secrets/%s' % token)
|
||||
assert peek.get_json()['data']['available'] is False
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ def test_an_expired_secret_is_gone(client, db, auth_headers):
|
||||
- timedelta(minutes=1))
|
||||
db.session.commit()
|
||||
|
||||
assert client.post('/api/securesend/%s' % token).status_code == 404
|
||||
assert client.post('/api/tools/secrets/%s' % token).status_code == 404
|
||||
assert SecureSecret.query.filter_by(token=token).first() is None
|
||||
|
||||
|
||||
@@ -152,10 +152,10 @@ def test_an_unknown_token_answers_the_same_as_a_spent_one(client, auth_headers):
|
||||
"""Distinguishing them would confirm to anyone holding a guessed token
|
||||
whether it ever named anything."""
|
||||
token = _create(client, auth_headers).get_json()['data']['token']
|
||||
client.post('/api/securesend/%s' % token)
|
||||
client.post('/api/tools/secrets/%s' % token)
|
||||
|
||||
spent = client.post('/api/securesend/%s' % token)
|
||||
never = client.post('/api/securesend/%s' % ('f' * 32))
|
||||
spent = client.post('/api/tools/secrets/%s' % token)
|
||||
never = client.post('/api/tools/secrets/%s' % ('f' * 32))
|
||||
assert spent.status_code == never.status_code == 404
|
||||
assert (spent.get_json()['data']['error']['message']
|
||||
== never.get_json()['data']['error']['message'])
|
||||
Reference in New Issue
Block a user