diff --git a/shopdb/plugins/__init__.py b/shopdb/plugins/__init__.py index 02d232b8..8dd7c4d1 100644 --- a/shopdb/plugins/__init__.py +++ b/shopdb/plugins/__init__.py @@ -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. diff --git a/tests/test_plugin_migrations.py b/tests/test_plugin_migrations.py index 37917a72..ed669573 100644 --- a/tests/test_plugin_migrations.py +++ b/tests/test_plugin_migrations.py @@ -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