Seed plugin permissions and relink scopes on plugin upgrade

on_install/on_enable fire only on a state transition, so a permission declared by a later plugin version never reached an already-installed site through them. Until someone ran flask seed permissions by hand the name did not exist, and any route guarding on it refused every caller. Settings already had sync_all_plugin_settings() on the upgrade path for exactly this reason; permissions now have the counterpart.

The scope relink is the half that actually bites. Creating the Permission row only makes the name exist - the prebuilt scopes derive their grants from the catalog, so a permission seeded without a following Role.seedscopes() belongs to nobody but admin. The plugin installs clean and its UI is invisible to every non-admin, which reads as a broken plugin rather than a seeding gap. Ordering matches flask seed permissions: seedscopes only links rows that already exist, so it runs last. Additive, so a site's deliberate role edits survive the deploy, and best-effort per plugin so one bad plugin cannot abort a deploy.

Test deletes an existing plugin permission, runs upgrade-all, and asserts both that the row returns and that it is granted to the technician scope. Verified it fails without the fix rather than passing vacuously.
This commit is contained in:
benjamin.baker
2026-09-07 12:43:39 -04:00
parent 90dc6eb832
commit 5de6a2b170
2 changed files with 120 additions and 0 deletions

View File

@@ -120,6 +120,13 @@ class PluginManager:
# upgrade is the only moment left to seed them. Runs even with no
# migration manager - it is independent of the Alembic chains.
self.sync_all_plugin_settings()
# Permissions need the same treatment for the same reason. A permission
# added in a later plugin version would otherwise exist only after a
# manual `flask seed permissions`, and until someone ran it the route
# guarding on that name would refuse every caller - including admins on
# sites that gate by permission rather than role. Deploys run
# upgrade-all, so this is the moment the new name has to appear.
self.sync_all_plugin_permissions()
if not self.migration_manager:
return results
# Only ADOPTED plugins (those in the registry) get migrated. A plugin
@@ -159,6 +166,51 @@ class PluginManager:
touched += 1
return touched
def sync_all_plugin_permissions(self) -> int:
"""Seed every enabled plugin's declared permissions, then relink scopes.
Returns the number of plugins whose permissions were seeded. Best-effort
per plugin: one bad plugin must not abort a deploy's upgrade pass.
The scope relink is the load-bearing half. Creating the Permission row
only makes the name exist; the prebuilt scopes derive their grants from
the catalog, so a permission seeded without a following
Role.seedscopes() belongs to nobody but admin. The symptom is a plugin
that installs cleanly and whose UI is invisible to every non-admin until
someone re-runs the seed by hand. Ordering matters for the same reason
it does in `flask seed permissions`: seedscopes only links rows that
already exist, so it runs last.
"""
touched = 0
for name in list(self.registry.get_all().keys()):
if not self.registry.is_enabled(name):
continue
try:
plugin = self.loader.load_plugin(name, self._app, self._db)
except Exception:
logger.exception(
"Could not load %s to sync its permissions", name)
continue
if plugin:
self._seed_plugin_permissions(plugin)
touched += 1
# Additive: this can hand a scope a newly declared permission, never
# take one away, so a site's deliberate role edits survive the deploy.
from shopdb.core.models import Role
try:
with self._app.app_context():
result = Role.seedscopes()
self._db.session.commit()
granted = sum((result or {}).get('granted', {}).values())
if granted:
logger.info(
"Linked %d new scope grant(s) after plugin upgrade",
granted)
except Exception:
logger.exception("Could not relink prebuilt scopes after upgrade")
return touched
def get_declared_plugin_settings(self) -> Dict[str, dict]:
"""Return {key: declared entry} across all loaded plugins.

View File

@@ -399,3 +399,71 @@ def test_baseline_skips_tables_that_already_exist(plugin, tmp_path, monkeypatch)
(plugin_manager._app, plugin_manager._db, plugin_manager.registry,
plugin_manager.loader, plugin_manager.migration_manager,
plugin_manager._registered_prefixes) = saved
def test_upgrade_all_seeds_permissions_and_links_them_to_scopes(
tmp_path, monkeypatch):
"""`flask plugin upgrade-all` seeds plugin permissions AND relinks scopes.
on_install/on_enable fire only on a state transition, so a permission
declared by a LATER plugin version never reaches an already-installed site
through them. Upgrade is the only remaining moment. Before this was wired,
the name existed only after a manual `flask seed permissions`.
The scope relink is the half that actually bites. Creating the Permission
row only makes the name exist - the prebuilt scopes derive their grants from
the catalog, so a permission seeded without a following seedscopes() belongs
to nobody but admin. The plugin installs clean and its UI is invisible to
every non-admin, which reads as a broken plugin rather than a seeding gap.
"""
from shopdb.config import TestingConfig
from shopdb import create_app
from shopdb.extensions import db
from shopdb.core.models import Permission, Role
from shopdb.plugins import plugin_manager
db_file = tmp_path / 'perms.db'
monkeypatch.setattr(TestingConfig, 'SQLALCHEMY_DATABASE_URI',
f'sqlite:///{db_file}')
saved = (plugin_manager._app, plugin_manager._db, plugin_manager.registry,
plugin_manager.loader, plugin_manager.migration_manager,
plugin_manager._registered_prefixes)
try:
app = create_app('testing')
with app.app_context():
db.create_all()
Permission.seed()
Role.seedscopes()
db.session.commit()
pm = app.extensions['plugin_manager']
# Stand in for a permission that a later plugin version declares:
# delete a plugin-owned view permission so upgrade-all has to put it
# back the way it would for a genuinely new one.
target = Permission.query.filter_by(name='endpoints.view').first()
assert target is not None, 'fixture expects endpoints.view to exist'
for role in list(target.roles):
role.permissions.remove(target)
db.session.delete(target)
db.session.commit()
assert Permission.query.filter_by(name='endpoints.view').first() is None
pm.upgrade_all_plugins()
# The row is back...
restored = Permission.query.filter_by(name='endpoints.view').first()
assert restored is not None, (
'upgrade-all did not re-seed a missing plugin permission')
# ...and it is actually granted to the read-capable scopes, not
# merely present. This is the assertion that fails if seedscopes()
# is dropped from the upgrade path.
holders = {role.rolename for role in restored.roles}
assert 'technician' in holders, (
f'endpoints.view reseeded but not linked to any scope: {holders}')
finally:
(plugin_manager._app, plugin_manager._db, plugin_manager.registry,
plugin_manager.loader, plugin_manager.migration_manager,
plugin_manager._registered_prefixes) = saved