Add the get_permissions plugin hook (contract 0.10.0)
All checks were successful
CI / backend (push) Successful in 1m20s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s

Plugins declare their own RBAC permissions instead of core accumulating
them: 36 permissions moved out of the core catalog into the 9 owning
plugins (core keeps the 19 its own blueprints enforce). The catalog is
resolved dynamically (core + enabled plugins) and feeds the roles grid,
the token scope picker and ceiling, and flask seed permissions;
installing or enabling a plugin seeds its permissions automatically. A
disabled plugin drops out of the assignable catalog while existing role
links keep working. New plugins - bundled or external - now bring their
permissions with zero core edits.

781 tests pass; live-verified with a machines.edit-scoped token.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-12 09:29:55 -04:00
parent 12175169e4
commit 7dfbe7bf8a
22 changed files with 439 additions and 90 deletions

View File

@@ -0,0 +1,90 @@
"""Tests for the get_permissions hook consumer chain (contract 0.10.0).
Pins full_permission_catalog() and its consumers: the core catalog holds no
plugin permissions, an enabled plugin's permissions merge in and drop out when
disabled, seeding is idempotent, API-token scope validation follows the enabled
set, and the role grid groups plugin categories.
Plugin enabled-state is monkeypatched (not persisted) so these tests do not
mutate the shared instance/plugins.json registry file.
"""
from shopdb.core.models import Permission, full_permission_catalog
from shopdb.core.models.apitoken import ApiToken
# Permission-owning plugin domains that must NOT live in the core catalog.
PLUGIN_PREFIXES = ('machines.', 'computers.', 'printers.', 'network.', 'kb.',
'notifications.', 'usb.', 'warranty.', 'measuringtools.')
def test_core_catalog_excludes_plugin_permissions():
"""Permission.CORE_PERMISSIONS holds only genuinely core permissions."""
names = {n for n, _d, _c in Permission.CORE_PERMISSIONS}
for name in names:
assert not name.startswith(PLUGIN_PREFIXES), (
f'{name} is a plugin permission and must move to its plugin hook')
# Core sets stay put.
assert 'assets.edit' in names
assert 'settings.edit' in names
assert 'collector.ingest' in names
def test_enabled_plugin_permissions_merge(app, monkeypatch):
"""An enabled plugin's permissions appear in full_permission_catalog()."""
pm = app.extensions['plugin_manager']
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
with app.app_context():
names = {n for n, _d, _c in full_permission_catalog()}
assert 'machines.edit' in names
assert 'printers.edit' in names
# core still present alongside plugin permissions
assert 'assets.edit' in names
def test_disabled_plugin_permissions_drop_out(app, monkeypatch):
"""A disabled plugin's permissions disappear from the catalog."""
pm = app.extensions['plugin_manager']
monkeypatch.setattr(pm.registry, 'is_enabled',
lambda name: name != 'machines')
with app.app_context():
names = {n for n, _d, _c in full_permission_catalog()}
assert 'machines.edit' not in names
assert 'printers.edit' in names # other plugins still enabled
def test_seed_is_idempotent(app, db, monkeypatch):
"""Seeding the full catalog twice creates each permission once."""
pm = app.extensions['plugin_manager']
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
with app.app_context():
first = Permission.seed()
db.session.commit()
assert first > len(Permission.CORE_PERMISSIONS) # plugins contributed
second = Permission.seed()
db.session.commit()
assert second == 0
def test_token_scope_validation_follows_enabled_set(app, monkeypatch):
"""A plugin permission is an accepted scope only while the plugin is enabled."""
pm = app.extensions['plugin_manager']
with app.app_context():
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
assert ApiToken.unknown_scope_names(['machines.edit']) == []
assert ApiToken.unknown_scope_names(['assets.edit']) == []
monkeypatch.setattr(pm.registry, 'is_enabled',
lambda name: name != 'machines')
assert ApiToken.unknown_scope_names(['machines.edit']) == ['machines.edit']
def test_list_permissions_endpoint_groups_plugin_category(
app, client, auth_headers, monkeypatch):
"""GET /api/users/permissions surfaces enabled plugins' categories."""
pm = app.extensions['plugin_manager']
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
response = client.get('/api/users/permissions', headers=auth_headers)
assert response.status_code == 200, response.get_json()
grouped = response.get_json()['data']['grouped']
assert 'assets' in grouped # core category always present
assert 'machines' in grouped # enabled plugin category merged in

View File

@@ -156,6 +156,28 @@ def test_plugin_get_reports_is_iterable(plugin_instances, name):
)
def test_baseplugin_has_permissions_hook():
"""The permissions hook is on the contract surface (contract 0.10.0)."""
assert hasattr(BasePlugin, 'get_permissions')
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
def test_plugin_get_permissions_shape(plugin_instances, name):
"""get_permissions returns a list of (name, description, category) entries."""
plugin = plugin_instances[name]
perms = plugin.get_permissions()
assert isinstance(perms, list)
for entry in perms:
if isinstance(entry, dict):
pname, category = entry['name'], entry.get('category')
else:
assert len(entry) == 3, f'{name}: entry must be a 3-tuple'
pname, _desc, category = entry
assert isinstance(pname, str) and '.' in pname, (
f'{name}: permission name {pname!r} must be dotted')
assert category, f'{name}: permission {pname} needs a category'
def test_baseplugin_has_frontend_contribution_hooks():
"""The four ADR-010 frontend-contribution hooks are on the contract (0.7.0)."""
for hook in ('get_settings_cards', 'get_asset_panels',