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.