The habit this replaces is pasting a credential into Teams, where it stays until someone deletes the conversation. Tabled on 2026-08-21 pending a decision about scope; the decision was text only, so this is the half that survives it. BACKEND ONLY. There is no page yet, so nothing here is reachable by a person. TEXT, NOT DOCUMENTS, and that shapes everything. Dropping files removed the export-control question entirely - a password is not technical data - along with blob storage, size and MIME handling, and the need to authenticate the recipient so a document fetch could be pinned to a name. TWO DOORS. Creating needs a login AND securesend.create: anonymous creation would be an untraceable way to move data out of a system that otherwise records who did what, and the creator is the only person these rows can ever name. Reading needs neither, because the recipient usually has no account here and the link is handed over on a call - the unguessable token IS the authorisation, which is why it is 128 bits of randomness and why the row is spent on first read. REDEEMING IS A POST, and that is the detail that would have broken this in practice. Teams, Outlook and Safe Links prefetch GET urls to build previews and scan for malware, so a GET that redeemed would let a preview window consume a secret before its recipient ever saw it. The GET route that remains reports only whether a link is still live, so the read page can say "opening this will destroy it" without spending the only view to find out. THE SIZE LIMIT IS THE WHOLE ENFORCEMENT. The key lives in the URL fragment, which browsers never transmit, so the server holds ciphertext it cannot read and cannot tell a password from a page of a drawing. MAX_CIPHERTEXT is therefore not a tuning knob: raising it quietly turns this into the document transfer it was scoped not to be. The refusal says what the limit is FOR, because somebody hitting it is usually trying to send a file and the answer is a different tool. Line counts, file signatures and refusing a pasted file belong in the browser, guide an honest person, and are bypassable by anyone willing to POST directly. NOT ENUMERABLE. Missing, expired and spent all answer identically - telling them apart would confirm to anyone holding a guessed token whether it ever named anything. A spent or expired row is deleted on the way past rather than waiting for a sweeper, because it has no reason to keep its ciphertext. Its own plugin rather than a Tech Tool: that plugin is browser-only by design, and this needs storage, expiry and a view counter. Not an asset extension either, so no assetid and no asset type - the scaffold assumes one and it was removed. FOUND BY ITS OWN TESTS: expiryhours=0 returned 201. `int(x or DEFAULT)` treats an explicit zero as absent, so "expire immediately" silently became "expire in a day". A caller who sends a number is now told when it is not allowed rather than quietly given a different one. BEFORE ANYONE RELIES ON THIS, test that your link rewriter preserves URL fragments: create a secret, send the link to yourself in Teams, click it from the message, and check the key is still after the #. If Safe Links strips it the secret is unrecoverable and the server cannot help, which is the point of the design. The fallback is passphrase mode, worth building only if that test says so. Three product gates caught what a new plugin has to declare, and each was right to: the route-parity gate wanted the endpoints described in api-inventory.json, the lean-build guard wanted the plugin listed in the universal profile the released installer builds from, and the authz sweep wanted the public redeem route named in EXEMPT_ENDPOINTS with a reason. That last one matters most - a mutating route that answers an unauthenticated caller is a decision somebody has to write down, not a default, and reading a secret is a mutation because it spends it.
196 lines
8.4 KiB
Python
196 lines
8.4 KiB
Python
"""Shared Alembic env.py logic for bundled plugins.
|
|
|
|
Every bundled plugin that owns tables (computers, employees, knowledgebase,
|
|
machines, network, notifications, printers, slides, usb, warranty) has a
|
|
`migrations/env.py` that does the minimum:
|
|
|
|
import os
|
|
os.environ['PLUGIN_NAME'] = 'computers'
|
|
from shopdb.plugins.alembic_template import run_migrations
|
|
run_migrations()
|
|
|
|
This module wires the plugin's models into a MetaData object filtered to
|
|
only the tables that belong to that plugin, then runs Alembic in either
|
|
offline or online mode against the Flask app's configured engine.
|
|
|
|
Ownership cutover (see ADR-008): the core Alembic chain created every table
|
|
that exists through its head (`7d16_directoryemployees`), including the plugin
|
|
tables. Each plugin's `0001` migration is therefore a stamp-only no-op that
|
|
just records the anchor revision in `alembic_version_<plugin>`. NEW plugin
|
|
schema changes land as `plugins/<name>/migrations/000N` from here on, never in
|
|
the core chain.
|
|
|
|
Plugin tables must be importable via `plugins.<name>.models`. Plugins
|
|
register their `__tablename__` set in PLUGIN_TABLE_OWNERS below so the
|
|
filter is explicit (avoids depending on import-side-effect global state).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import logging
|
|
import os
|
|
from typing import Iterable
|
|
|
|
from alembic import context
|
|
from sqlalchemy import MetaData, pool
|
|
|
|
# Registers the compiler hook that forces utf8mb4 + DYNAMIC on MySQL. Imported
|
|
# for the side effect. It used to live inline in migrations/env.py, so it applied
|
|
# to the CORE chain only: a plugin's baseline tables were created at the server's
|
|
# default charset while core's were utf8mb4, on the same database. On a server
|
|
# defaulting to latin1 that difference is invisible until a join between the two
|
|
# stops using an index, or a character comes back mangled.
|
|
import shopdb.utils.mysql_charset # noqa: F401,E402
|
|
|
|
logger = logging.getLogger('alembic.env.plugin')
|
|
|
|
# Explicit table-ownership map. Adding tables to a plugin requires updating
|
|
# this dict so the per-plugin migration knows which tables to include.
|
|
PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
|
|
'backups': ('backuprevisions',),
|
|
'computers': ('computertypes', 'computers', 'computerinstalledapps',
|
|
'accessprotocols', 'computeraccess'),
|
|
'employees': ('directoryemployees',),
|
|
'geenforce': ('manifestscopes', 'manifestentries', 'manifestentrypctypes',
|
|
'manifestentryhostnames', 'manifestentrymachinenumbers',
|
|
'manifestinusechecks', 'manifestinusecheckprocesses',
|
|
'manifestpublishedversions', 'manifestpayloads',
|
|
'manifestblobs',
|
|
'manifestenforcementreports', 'manifestenforcementresults',
|
|
'pctypealiases'),
|
|
'knowledgebase': ('knowledgebase',),
|
|
'machines': ('machinetypes', 'machines'),
|
|
'measuringtools': ('measuringtooltypes', 'measuringtools'),
|
|
'network': ('networkdevicetypes', 'networkdevices', 'vlans', 'subnets'),
|
|
'notifications': ('notificationtypes', 'notifications'),
|
|
'printedparts': ('printeditems', 'printeditemtransactions',
|
|
'printeditemfiles'),
|
|
'printers': ('printertypes', 'printers', 'modelsupplies', 'printerdrivers',
|
|
'printersupplyalerts', 'printerobservedqueues'),
|
|
'securesend': ('securesendsecrets',),
|
|
'slides': ('tvslides',),
|
|
'usb': ('usbdevicetypes', 'usbdevices', 'usbcheckouts'),
|
|
'warranty': ('warranties', 'warrantyassets'),
|
|
}
|
|
|
|
|
|
def _get_plugin_metadata(plugin_name: str) -> MetaData:
|
|
"""Import the plugin's models and return a MetaData containing only its
|
|
declared tables (filtered via PLUGIN_TABLE_OWNERS)."""
|
|
owned = set(PLUGIN_TABLE_OWNERS.get(plugin_name, ()))
|
|
if not owned:
|
|
raise RuntimeError(
|
|
f"PLUGIN_TABLE_OWNERS has no entry for plugin '{plugin_name}'. "
|
|
f"Update shopdb/plugins/alembic_template.py."
|
|
)
|
|
|
|
# Importing models attaches them to the global db.metadata.
|
|
importlib.import_module(f'plugins.{plugin_name}.models')
|
|
from shopdb.extensions import db
|
|
full = db.metadata
|
|
|
|
plugin_md = MetaData()
|
|
for table in list(full.tables.values()):
|
|
if table.name in owned:
|
|
table.to_metadata(plugin_md)
|
|
return plugin_md
|
|
|
|
|
|
def create_plugin_tables(plugin_name: str):
|
|
"""Create every table this plugin owns, sourced from the SQLAlchemy models
|
|
(not duplicated DDL). IDEMPOTENT: a table that already exists is skipped, so
|
|
this is safe on an existing database that has the table from the pre-cutover
|
|
core baseline as well as on a fresh install (ADR-014 Phase 2).
|
|
|
|
Called from each plugin's 0001 baseline.py upgrade().
|
|
"""
|
|
from alembic import op
|
|
from sqlalchemy import inspect
|
|
from sqlalchemy.schema import CreateTable
|
|
|
|
md = _get_plugin_metadata(plugin_name)
|
|
bind = op.get_bind()
|
|
existing = set(inspect(bind).get_table_names())
|
|
# Sort by FK dependency so parent tables are created first.
|
|
for table in md.sorted_tables:
|
|
if table.name in existing:
|
|
continue
|
|
op.execute(str(CreateTable(table).compile(dialect=bind.dialect)))
|
|
|
|
|
|
def drop_plugin_tables(plugin_name: str):
|
|
"""Mirror of create_plugin_tables for downgrade(). Drops in reverse FK
|
|
order."""
|
|
from alembic import op
|
|
|
|
md = _get_plugin_metadata(plugin_name)
|
|
for table in reversed(md.sorted_tables):
|
|
op.execute(f'DROP TABLE IF EXISTS "{table.name}"')
|
|
|
|
|
|
def run_migrations():
|
|
"""Entry point called by each plugin's migrations/env.py."""
|
|
plugin_name = os.environ.get('PLUGIN_NAME')
|
|
if not plugin_name:
|
|
raise RuntimeError("PLUGIN_NAME env var must be set before run_migrations()")
|
|
|
|
config = context.config
|
|
target_metadata = _get_plugin_metadata(plugin_name)
|
|
|
|
# Per-plugin version table so each plugin's chain is independent of core
|
|
# Alembic's alembic_version table.
|
|
version_table = f'alembic_version_{plugin_name}'
|
|
|
|
db_url = config.get_main_option('sqlalchemy.url')
|
|
if not db_url:
|
|
# Pull from the Flask app config if running inside an app context
|
|
# (e.g. via flask plugin migrate <name>).
|
|
try:
|
|
from flask import current_app
|
|
db_url = current_app.config['SQLALCHEMY_DATABASE_URI']
|
|
config.set_main_option('sqlalchemy.url', db_url.replace('%', '%%'))
|
|
except Exception as ex:
|
|
raise RuntimeError(
|
|
"sqlalchemy.url not set and no Flask app context available. "
|
|
f"Original error: {ex}"
|
|
)
|
|
|
|
if context.is_offline_mode():
|
|
context.configure(
|
|
url=db_url,
|
|
target_metadata=target_metadata,
|
|
literal_binds=True,
|
|
version_table=version_table,
|
|
include_schemas=False,
|
|
)
|
|
with context.begin_transaction():
|
|
context.run_migrations()
|
|
else:
|
|
# Build the engine straight from the resolved URL. The plugin manager
|
|
# drives this via a programmatic alembic Config (no ini file), so
|
|
# config.get_section returns an empty dict and engine_from_config would
|
|
# find no sqlalchemy.url. db_url is already resolved above.
|
|
from sqlalchemy import create_engine
|
|
connectable = create_engine(db_url, poolclass=pool.NullPool)
|
|
with connectable.connect() as connection:
|
|
context.configure(
|
|
connection=connection,
|
|
target_metadata=target_metadata,
|
|
version_table=version_table,
|
|
include_schemas=False,
|
|
)
|
|
with context.begin_transaction():
|
|
context.run_migrations()
|
|
# SAME FIX AS migrations/env.py. MySQL DDL commits implicitly, which
|
|
# flushes everything queued before it - including the PREVIOUS
|
|
# migration's version stamp. The LAST migration of a run has no DDL
|
|
# after it, so its stamp is rolled back when the connection closes:
|
|
# the schema change survives, alembic_version stays one revision
|
|
# behind, and `flask plugin upgrade-all` exits 0 having silently
|
|
# re-run that migration - again on every deploy after.
|
|
#
|
|
# Core got this fix; the per-plugin chains run through THIS file and
|
|
# did not, so every plugin head has been re-running on MySQL. That is
|
|
# only invisible while every head happens to be idempotent.
|
|
connection.commit()
|