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

@@ -12,6 +12,23 @@ ADR-007 and ADR-002.
### Added ### Added
- Plugin `get_permissions` hook (contract 0.10.0) so a plugin declares the RBAC
permissions its own routes enforce, instead of core accumulating every
plugin's permissions in `Permission.PERMISSIONS` (plugin-is-the-product). The
core catalog (`Permission.CORE_PERMISSIONS`) now holds only genuinely core
sets (assets, applications, reports, settings, users, audit, apitokens,
collector); the 36 permissions for machines, computers, printers, network,
knowledgebase, notifications, usb, warranty, and measuringtools moved into
each owning plugin's hook. New core helper `full_permission_catalog()` merges
core plus every ENABLED plugin's permissions and backs all three consumers:
`flask seed permissions`, the role grid (`GET /api/users/permissions`), and
API-token scope validation (`ApiToken.unknown_scope_names`). Plugin install
and enable seed the plugin's own permissions idempotently. A disabled plugin
drops out of the catalog (no new scope grants or role assignments), but its
existing `Permission` rows and role links persist so current roles keep
working. Docs: `docs/PLUGIN-HOOKS.md` new section, `docs/PLUGIN-GUIDE.md`
permissions walkthrough rewritten to the hook, `docs/PLUGIN-QUICKSTART.md`
hooks table row.
- Personal API tokens (PATs) so scripts and integrations authenticate without - Personal API tokens (PATs) so scripts and integrations authenticate without
the hourly-expiring login JWT (immediate consumer: long legacy-import runs the hourly-expiring login JWT (immediate consumer: long legacy-import runs
that die when the JWT expires mid-run). New core `apitokens` table + migration that die when the JWT expires mid-run). New core `apitokens` table + migration

View File

@@ -330,20 +330,27 @@ def create_tool():
... ...
``` ```
The `measuringtools.*` permissions are seeded exactly the way warranty seeds its The `measuringtools.*` permissions belong to the plugin, not to core. The plugin
own, by adding them to `Permission.PERMISSIONS` in `shopdb/core/models/user.py`: declares them from the `get_permissions` hook (contract 0.10.0) so core never edits
its catalog to accommodate a plugin:
```python ```python
# Measuring tools class MeasuringToolsPlugin(BasePlugin):
def get_permissions(self):
return [
('measuringtools.view', 'View measuring tools', 'measuringtools'), ('measuringtools.view', 'View measuring tools', 'measuringtools'),
('measuringtools.create', 'Create measuring tools', 'measuringtools'), ('measuringtools.create', 'Create measuring tools', 'measuringtools'),
('measuringtools.edit', 'Edit measuring tools', 'measuringtools'), ('measuringtools.edit', 'Edit measuring tools', 'measuringtools'),
('measuringtools.delete', 'Delete measuring tools', 'measuringtools'), ('measuringtools.delete', 'Delete measuring tools', 'measuringtools'),
]
``` ```
`flask seed permissions` is idempotent, so re-running it just adds the four new Installing or enabling the plugin seeds these rows automatically, and
rows. The `admin` role bypasses every permission check, so an admin can operate the `flask seed permissions` (which now seeds core plus every enabled plugin) is
plugin before anyone grants the granular permissions. idempotent, so re-running it just adds any missing rows. The `admin` role bypasses
every permission check, so an admin can operate the plugin before anyone grants the
granular permissions. See `get_permissions` in `docs/PLUGIN-HOOKS.md` for the
disabled-plugin edge case.
**Responses use the framework helpers.** `success_response`, `error_response` (with **Responses use the framework helpers.** `success_response`, `error_response` (with
`ErrorCodes`), and `paginated_response` produce the standard envelope `ErrorCodes`), and `paginated_response` produce the standard envelope
@@ -666,7 +673,7 @@ When you build a plugin, confirm all of this before you call it done:
- [ ] Imports only from `shopdb.api` and `shopdb.plugins.base` (contract test green). - [ ] Imports only from `shopdb.api` and `shopdb.plugins.base` (contract test green).
- [ ] Blueprint: jwt-optional reads, permission-gated writes; framework response and - [ ] Blueprint: jwt-optional reads, permission-gated writes; framework response and
pagination helpers; audit logs on writes. pagination helpers; audit logs on writes.
- [ ] Permissions added to `Permission.PERMISSIONS`; `flask seed permissions` run. - [ ] Permissions declared from the `get_permissions` hook; install/enable (or `flask seed permissions`) seeds them.
- [ ] `on_install` seeds the asset type and any reference data, idempotently. - [ ] `on_install` seeds the asset type and any reference data, idempotently.
- [ ] Hooks: navigation, reports, models implemented; config schema and collector - [ ] Hooks: navigation, reports, models implemented; config schema and collector
implemented or consciously skipped with a reason. implemented or consciously skipped with a reason.

View File

@@ -9,7 +9,7 @@ The contract is locked in [ADR-001](../docs/adr/ADR-001-asset-as-platform-contra
The framework declares its contract version in `shopdb/__init__.py`: The framework declares its contract version in `shopdb/__init__.py`:
```python ```python
__contract_version__ = '0.9.0' __contract_version__ = '0.10.0'
``` ```
Each plugin's `manifest.json` declares the range of contract versions it supports: Each plugin's `manifest.json` declares the range of contract versions it supports:
@@ -216,6 +216,47 @@ Consumed by `GET /api/reports`, which merges plugin cards after the static core
reports sorted into category groups by the frontend (disabled plugins are reports sorted into category groups by the frontend (disabled plugins are
skipped; a broken plugin is isolated in prod, re-raised in dev/test). skipped; a broken plugin is isolated in prod, re-raised in dev/test).
### `get_permissions() -> List`
Returns the RBAC permissions this plugin owns. Added in contract 0.10.0. A
plugin declares the permission names its own routes enforce via
`require_permission`, instead of core accumulating every plugin's permissions in
one catalog (plugin-is-the-product).
Each entry is a `(name, description, category)` tuple, matching the core
permission catalog shape (dicts with those keys are also accepted). Names follow
the naming convention (lowercase dotted, e.g. `machines.edit`).
```python
class MachinesPlugin(BasePlugin):
def get_permissions(self):
return [
('machines.view', 'View machines', 'machines'),
('machines.create', 'Create machines', 'machines'),
('machines.edit', 'Edit machines', 'machines'),
('machines.delete', 'Delete machines', 'machines'),
]
```
Consumed by the core helper `full_permission_catalog()` (core permissions plus
every ENABLED plugin's `get_permissions()`), which backs three consumers:
- `flask seed permissions` seeds the full catalog.
- The role-management grid (`GET /api/users/permissions`) lists it, grouped by
category.
- API-token scope validation (`ApiToken.unknown_scope_names`) accepts a plugin
permission as a scope only while that plugin is enabled.
Plugin install and enable also seed the plugin's own permissions idempotently,
so enabling a fresh plugin creates its `Permission` rows without a separate seed
pass.
Disabled-plugin edge case: a disabled plugin is skipped by the catalog, so its
permissions are no longer offered for new scope grants or new role assignments.
The `Permission` ROWS already in the database are NOT deleted, so roles that
already reference them keep working until an admin edits the role. A broken
plugin is isolated in prod and re-raised in dev/test.
### `get_settings_cards() -> List[Dict]` ### `get_settings_cards() -> List[Dict]`
Returns settings-catalog card definitions. Added in contract 0.7.0 (ADR-010). Returns settings-catalog card definitions. Added in contract 0.7.0 (ADR-010).

View File

@@ -123,6 +123,7 @@ Override hooks on the plugin class as needed. See [PLUGIN-HOOKS.md](PLUGIN-HOOKS
| `get_dashboard_widgets` | Plugin's dashboard widget appears on the home page | | `get_dashboard_widgets` | Plugin's dashboard widget appears on the home page |
| `get_reports` | Plugin's report cards appear on the Reports hub | | `get_reports` | Plugin's report cards appear on the Reports hub |
| `get_settings_cards` | Plugin's card joins the settings rail + landing (no `settingsNav.js` edit) | | `get_settings_cards` | Plugin's card joins the settings rail + landing (no `settingsNav.js` edit) |
| `get_permissions` | Plugin's RBAC permissions join the catalog, seeding, role grid, and token scopes |
| `get_asset_panels` | Plugin panel renders on matching asset-detail pages | | `get_asset_panels` | Plugin panel renders on matching asset-detail pages |
| `get_map_overlays` | Plugin decorates shop-floor map markers + adds a legend entry | | `get_map_overlays` | Plugin decorates shop-floor map markers + adds a legend entry |
| `get_asset_presentation` | Plugin declares its asset type's search icon + detail route | | `get_asset_presentation` | Plugin declares its asset type's search icon + detail route |

View File

@@ -384,3 +384,12 @@ class ComputersPlugin(BasePlugin):
'position': 15, 'position': 15,
}, },
] ]
def get_permissions(self) -> List:
"""Return the RBAC permissions this plugin owns."""
return [
('computers.view', 'View computers', 'computers'),
('computers.create', 'Create computers', 'computers'),
('computers.edit', 'Edit computers', 'computers'),
('computers.delete', 'Delete computers', 'computers'),
]

View File

@@ -72,3 +72,12 @@ class KnowledgeBasePlugin(BasePlugin):
'section': 'information', 'section': 'information',
}, },
] ]
def get_permissions(self) -> List:
"""Return the RBAC permissions this plugin owns."""
return [
('kb.view', 'View knowledge base', 'knowledgebase'),
('kb.create', 'Create KB articles', 'knowledgebase'),
('kb.edit', 'Edit KB articles', 'knowledgebase'),
('kb.delete', 'Delete KB articles', 'knowledgebase'),
]

View File

@@ -217,3 +217,12 @@ class MachinesPlugin(BasePlugin):
'position': 10, 'position': 10,
}, },
] ]
def get_permissions(self) -> List:
"""Return the RBAC permissions this plugin owns."""
return [
('machines.view', 'View machines', 'machines'),
('machines.create', 'Create machines', 'machines'),
('machines.edit', 'Edit machines', 'machines'),
('machines.delete', 'Delete machines', 'machines'),
]

View File

@@ -168,3 +168,12 @@ class MeasuringToolsPlugin(BasePlugin):
name=name, description=description, color=color)) name=name, description=description, color=color))
logger.debug(f"Created measuring-tool type: {name}") logger.debug(f"Created measuring-tool type: {name}")
db.session.commit() db.session.commit()
def get_permissions(self) -> List:
"""Return the RBAC permissions this plugin owns."""
return [
('measuringtools.view', 'View measuring tools', 'measuringtools'),
('measuringtools.create', 'Create measuring tools', 'measuringtools'),
('measuringtools.edit', 'Edit measuring tools', 'measuringtools'),
('measuringtools.delete', 'Delete measuring tools', 'measuringtools'),
]

View File

@@ -214,3 +214,12 @@ class NetworkPlugin(BasePlugin):
'position': 18, 'position': 18,
}, },
] ]
def get_permissions(self) -> List:
"""Return the RBAC permissions this plugin owns."""
return [
('network.view', 'View network devices', 'network'),
('network.create', 'Create network devices', 'network'),
('network.edit', 'Edit network devices', 'network'),
('network.delete', 'Delete network devices', 'network'),
]

View File

@@ -220,3 +220,12 @@ class NotificationsPlugin(BasePlugin):
'position': 6, 'position': 6,
}, },
] ]
def get_permissions(self) -> List:
"""Return the RBAC permissions this plugin owns."""
return [
('notifications.view', 'View notifications', 'notifications'),
('notifications.create', 'Create notifications', 'notifications'),
('notifications.edit', 'Edit notifications', 'notifications'),
('notifications.delete', 'Delete notifications', 'notifications'),
]

View File

@@ -230,3 +230,12 @@ class PrintersPlugin(BasePlugin):
'route': '/reports/toner', 'route': '/reports/toner',
}, },
] ]
def get_permissions(self) -> List:
"""Return the RBAC permissions this plugin owns."""
return [
('printers.view', 'View printers', 'printers'),
('printers.create', 'Create printers', 'printers'),
('printers.edit', 'Edit printers', 'printers'),
('printers.delete', 'Delete printers', 'printers'),
]

View File

@@ -110,3 +110,12 @@ class USBPlugin(BasePlugin):
'position': 45, 'position': 45,
}, },
] ]
def get_permissions(self) -> List:
"""Return the RBAC permissions this plugin owns."""
return [
('usb.view', 'View USB devices', 'usb'),
('usb.create', 'Create USB devices', 'usb'),
('usb.edit', 'Edit USB devices', 'usb'),
('usb.delete', 'Delete USB devices', 'usb'),
]

View File

@@ -88,5 +88,14 @@ class WarrantyPlugin(BasePlugin):
}, },
] ]
def get_permissions(self) -> List:
"""Return the RBAC permissions this plugin owns."""
return [
('warranty.view', 'View warranties', 'warranty'),
('warranty.create', 'Create warranties', 'warranty'),
('warranty.edit', 'Edit warranties', 'warranty'),
('warranty.delete', 'Delete warranties', 'warranty'),
]
def init_app(self, app: Flask, db_instance) -> None: def init_app(self, app: Flask, db_instance) -> None:
logger.info(f"Warranty plugin initialized (v{self.meta.version})") logger.info(f"Warranty plugin initialized (v{self.meta.version})")

View File

@@ -27,7 +27,12 @@ from .plugins import plugin_manager
# (resolve_dualpath_pairs, dualpath_single_machine_enabled), consumed by the # (resolve_dualpath_pairs, dualpath_single_machine_enabled), consumed by the
# machines plugin list/detail to collapse dual-bay pairs. Two additive names, # machines plugin list/detail to collapse dual-bay pairs. Two additive names,
# minor bump. # minor bump.
__contract_version__ = '0.9.0' # 0.10.0: added the get_permissions hook so a plugin declares the RBAC
# permissions its own routes enforce, instead of core accumulating them in one
# catalog. Consumed by full_permission_catalog() (core + enabled plugins),
# which backs seeding, the role grid, and API-token scope validation. Additive
# optional hook, minor bump.
__contract_version__ = '0.10.0'
# Product release version (see ADR-007). The product version and the # Product release version (see ADR-007). The product version and the
# plugin-contract version above are distinct series with independent # plugin-contract version above are distinct series with independent

View File

@@ -5,7 +5,8 @@ from flask_jwt_extended import jwt_required, current_user
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
from shopdb.extensions import db from shopdb.extensions import db
from shopdb.core.models import User, Role, Permission, AuditLog from shopdb.core.models import (
User, Role, Permission, AuditLog, full_permission_catalog)
from shopdb.utils.responses import success_response, error_response, ErrorCodes from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_role from shopdb.utils.authz import require_role
@@ -179,29 +180,29 @@ def delete_user(userid: int):
@users_bp.route('/permissions', methods=['GET']) @users_bp.route('/permissions', methods=['GET'])
@jwt_required() @jwt_required()
def list_permissions(): def list_permissions():
"""List all permissions grouped by category.""" """List assignable permissions grouped by category.
permissions = Permission.query.order_by(Permission.category, Permission.name).all()
Driven by full_permission_catalog() (core plus ENABLED plugins) so a
disabled plugin's permissions drop out of the role grid. Roles assign by
name; the permissionid comes from the seeded Permission row when present.
"""
idbyname = {p.name: p.permissionid for p in Permission.query.all()}
catalog = full_permission_catalog()
catalog.sort(key=lambda e: (e[2], e[0]))
# Group by category
grouped = {} grouped = {}
for p in permissions: flat = []
if p.category not in grouped: for name, description, category in catalog:
grouped[p.category] = [] entry = {
grouped[p.category].append({ 'permissionid': idbyname.get(name),
'permissionid': p.permissionid, 'name': name,
'name': p.name, 'description': description,
'description': p.description }
}) grouped.setdefault(category, []).append(entry)
flat.append({**entry, 'category': category})
return success_response({ return success_response({'permissions': flat, 'grouped': grouped})
'permissions': [{
'permissionid': p.permissionid,
'name': p.name,
'description': p.description,
'category': p.category
} for p in permissions],
'grouped': grouped
})
# Roles endpoints # Roles endpoints

View File

@@ -11,7 +11,7 @@ from .location import Location, LocationType
from .operatingsystem import OperatingSystem from .operatingsystem import OperatingSystem
from .relationship import AssetRelationship, RelationshipType, RelationshipTypePropagation from .relationship import AssetRelationship, RelationshipType, RelationshipTypePropagation
from .communication import Communication, CommunicationType from .communication import Communication, CommunicationType
from .user import User, Role, Permission from .user import User, Role, Permission, full_permission_catalog
from .application import Application, AppVersion from .application import Application, AppVersion
from .supportteam import SupportTeam, SupportTeamContact from .supportteam import SupportTeam, SupportTeamContact
from .setting import Setting from .setting import Setting
@@ -49,6 +49,7 @@ __all__ = [
'User', 'User',
'Role', 'Role',
'Permission', 'Permission',
'full_permission_catalog',
# Applications # Applications
'Application', 'Application',
'AppVersion', 'AppVersion',

View File

@@ -100,9 +100,12 @@ class ApiToken(BaseModel):
@staticmethod @staticmethod
def unknown_scope_names(names) -> list: def unknown_scope_names(names) -> list:
"""Return the subset of names that are not in the permission catalog.""" """Return the subset of names that are not in the permission catalog.
from shopdb.core.models.user import Permission
known = {name for name, _desc, _cat in Permission.PERMISSIONS} The catalog is core plus every ENABLED plugin's permissions, so a scope
naming a disabled plugin's permission is treated as unknown."""
from shopdb.core.models.user import full_permission_catalog
known = {name for name, _desc, _cat in full_permission_catalog()}
return [n for n in names if n not in known] return [n for n in names if n not in known]
def to_dict(self, include_owner: bool = False) -> dict: def to_dict(self, include_owner: bool = False) -> dict:

View File

@@ -33,63 +33,22 @@ class Permission(db.Model):
description = db.Column(db.String(255)) description = db.Column(db.String(255))
category = db.Column(db.String(50), default='general') # For grouping in UI category = db.Column(db.String(50), default='general') # For grouping in UI
# Predefined permissions # Core permission catalog. This is CORE ONLY - permissions for a plugin
PERMISSIONS = [ # domain (machines, computers, printers, network, knowledgebase,
# notifications, usb, warranty, measuringtools) live in that plugin's
# BasePlugin.get_permissions() hook, not here (plugin-is-the-product). The
# merged core+plugins view is full_permission_catalog() below.
CORE_PERMISSIONS = [
# Assets # Assets
('assets.view', 'View assets', 'assets'), ('assets.view', 'View assets', 'assets'),
('assets.create', 'Create assets', 'assets'), ('assets.create', 'Create assets', 'assets'),
('assets.edit', 'Edit assets', 'assets'), ('assets.edit', 'Edit assets', 'assets'),
('assets.delete', 'Delete assets', 'assets'), ('assets.delete', 'Delete assets', 'assets'),
# Machines
('machines.view', 'View machines', 'machines'),
('machines.create', 'Create machines', 'machines'),
('machines.edit', 'Edit machines', 'machines'),
('machines.delete', 'Delete machines', 'machines'),
# Computers
('computers.view', 'View computers', 'computers'),
('computers.create', 'Create computers', 'computers'),
('computers.edit', 'Edit computers', 'computers'),
('computers.delete', 'Delete computers', 'computers'),
# Printers
('printers.view', 'View printers', 'printers'),
('printers.create', 'Create printers', 'printers'),
('printers.edit', 'Edit printers', 'printers'),
('printers.delete', 'Delete printers', 'printers'),
# Network
('network.view', 'View network devices', 'network'),
('network.create', 'Create network devices', 'network'),
('network.edit', 'Edit network devices', 'network'),
('network.delete', 'Delete network devices', 'network'),
# Applications # Applications
('applications.view', 'View applications', 'applications'), ('applications.view', 'View applications', 'applications'),
('applications.create', 'Create applications', 'applications'), ('applications.create', 'Create applications', 'applications'),
('applications.edit', 'Edit applications', 'applications'), ('applications.edit', 'Edit applications', 'applications'),
('applications.delete', 'Delete applications', 'applications'), ('applications.delete', 'Delete applications', 'applications'),
# Knowledge Base
('kb.view', 'View knowledge base', 'knowledgebase'),
('kb.create', 'Create KB articles', 'knowledgebase'),
('kb.edit', 'Edit KB articles', 'knowledgebase'),
('kb.delete', 'Delete KB articles', 'knowledgebase'),
# Notifications
('notifications.view', 'View notifications', 'notifications'),
('notifications.create', 'Create notifications', 'notifications'),
('notifications.edit', 'Edit notifications', 'notifications'),
('notifications.delete', 'Delete notifications', 'notifications'),
# USB devices
('usb.view', 'View USB devices', 'usb'),
('usb.create', 'Create USB devices', 'usb'),
('usb.edit', 'Edit USB devices', 'usb'),
('usb.delete', 'Delete USB devices', 'usb'),
# Warranty
('warranty.view', 'View warranties', 'warranty'),
('warranty.create', 'Create warranties', 'warranty'),
('warranty.edit', 'Edit warranties', 'warranty'),
('warranty.delete', 'Delete warranties', 'warranty'),
# Measuring tools
('measuringtools.view', 'View measuring tools', 'measuringtools'),
('measuringtools.create', 'Create measuring tools', 'measuringtools'),
('measuringtools.edit', 'Edit measuring tools', 'measuringtools'),
('measuringtools.delete', 'Delete measuring tools', 'measuringtools'),
# Reports # Reports
('reports.view', 'View reports', 'reports'), ('reports.view', 'View reports', 'reports'),
('reports.export', 'Export reports', 'reports'), ('reports.export', 'Export reports', 'reports'),
@@ -117,16 +76,78 @@ class Permission(db.Model):
return f"<Permission {self.name}>" return f"<Permission {self.name}>"
@classmethod @classmethod
def seed(cls): def seed_entries(cls, entries):
"""Seed predefined permissions.""" """Idempotently create Permission rows from (name, desc, category)
tuples (or equivalent dicts). Returns how many were created."""
created = 0 created = 0
for name, description, category in cls.PERMISSIONS: for name, description, category in normalize_permission_entries(entries):
if not cls.query.filter_by(name=name).first(): if not cls.query.filter_by(name=name).first():
perm = cls(name=name, description=description, category=category) perm = cls(name=name, description=description, category=category)
db.session.add(perm) db.session.add(perm)
created += 1 created += 1
return created return created
@classmethod
def seed(cls):
"""Seed the full permission catalog (core plus enabled plugins)."""
return cls.seed_entries(full_permission_catalog())
def normalize_permission_entries(entries):
"""Coerce catalog entries to (name, description, category) tuples.
Accepts tuples/lists in that order, or dicts with those keys, so a plugin
may return either shape from get_permissions().
"""
normalized = []
for entry in entries or []:
if isinstance(entry, dict):
normalized.append((
entry['name'],
entry.get('description', ''),
entry.get('category', 'general'),
))
else:
name, description, category = entry
normalized.append((name, description, category))
return normalized
def full_permission_catalog(include_disabled=False):
"""The full permission catalog: core permissions plus every LOADED plugin's
get_permissions().
By default only ENABLED plugins contribute, so a disabled plugin's
permissions are not offered for new scope grants or role edits (existing
Permission rows and role links persist - see the get_permissions docstring).
Pass include_disabled=True to include all installed plugins.
Follows the dashboard.py consumer pattern: a broken plugin is re-raised in
dev/test and isolated (logged) in prod. Returns (name, description,
category) tuples.
"""
from flask import current_app, has_app_context
catalog = list(Permission.CORE_PERMISSIONS)
if not has_app_context():
return catalog
pm = current_app.extensions.get('plugin_manager')
if not pm:
return catalog
for name, plugin in pm.get_all_plugins().items():
if not include_disabled and not pm.registry.is_enabled(name):
continue
try:
catalog.extend(
normalize_permission_entries(plugin.get_permissions()))
except Exception:
if current_app.config.get('DEBUG') or current_app.config.get('TESTING'):
raise
current_app.logger.exception(
'Plugin %s get_permissions failed', name)
return catalog
class Role(BaseModel): class Role(BaseModel):
"""User role model.""" """User role model."""
@@ -156,7 +177,7 @@ class Role(BaseModel):
def getpermissionnames(self) -> list: def getpermissionnames(self) -> list:
"""Get list of permission names.""" """Get list of permission names."""
if self.rolename == 'admin': if self.rolename == 'admin':
return [p[0] for p in Permission.PERMISSIONS] return [p[0] for p in full_permission_catalog()]
return [p.name for p in self.permissions] return [p.name for p in self.permissions]
@@ -209,7 +230,7 @@ class User(BaseModel):
def getpermissions(self) -> list: def getpermissions(self) -> list:
"""Get list of all permission names from all roles.""" """Get list of all permission names from all roles."""
if self.hasrole('admin'): if self.hasrole('admin'):
return [p[0] for p in Permission.PERMISSIONS] return [p[0] for p in full_permission_catalog()]
perms = set() perms = set()
for role in self.roles: for role in self.roles:

View File

@@ -220,11 +220,35 @@ class PluginManager:
plugin = self.loader.load_plugin(name, self._app, self._db) plugin = self.loader.load_plugin(name, self._app, self._db)
if plugin: if plugin:
self._register_plugin_components(plugin) self._register_plugin_components(plugin)
self._seed_plugin_permissions(plugin)
plugin.on_install(self._app) plugin.on_install(self._app)
logger.info(f"Installed plugin: {name} v{manifest_version}") logger.info(f"Installed plugin: {name} v{manifest_version}")
return True return True
def _seed_plugin_permissions(self, plugin: BasePlugin) -> None:
"""Idempotently create Permission rows for a plugin's declared perms.
Runs at install and enable so a fresh plugin's RBAC permissions exist
without a separate `flask seed permissions` pass. Best-effort: a plugin
that raises must not abort the lifecycle."""
try:
entries = plugin.get_permissions() or []
except Exception:
logger.exception(
"get_permissions failed for %s", plugin.meta.name)
return
if not entries:
return
from shopdb.core.models import Permission
with self._app.app_context():
created = Permission.seed_entries(entries)
if created:
self._db.session.commit()
logger.info(
"Seeded %d permission(s) for plugin %s",
created, plugin.meta.name)
def uninstall_plugin(self, name: str, remove_data: bool = False) -> bool: def uninstall_plugin(self, name: str, remove_data: bool = False) -> bool:
""" """
Uninstall a plugin. Uninstall a plugin.
@@ -305,6 +329,7 @@ class PluginManager:
try: try:
plugin = self.loader.load_plugin(name, self._app, self._db) plugin = self.loader.load_plugin(name, self._app, self._db)
if plugin: if plugin:
self._seed_plugin_permissions(plugin)
plugin.on_enable(self._app) plugin.on_enable(self._app)
except Exception: except Exception:
logger.exception(f"on_enable hook failed for plugin {name}") logger.exception(f"on_enable hook failed for plugin {name}")

View File

@@ -209,6 +209,40 @@ class BasePlugin(ABC):
""" """
return [] return []
def get_permissions(self) -> List:
"""
Return the RBAC permissions this plugin owns.
Each entry is a (name, description, category) tuple, matching the core
permission catalog shape (dicts with those keys are also accepted):
[
('machines.view', 'View machines', 'machines'),
('machines.create', 'Create machines', 'machines'),
('machines.edit', 'Edit machines', 'machines'),
('machines.delete', 'Delete machines', 'machines'),
]
A plugin owns the permission names its own routes enforce via
require_permission; core no longer accumulates them. The names must
follow the naming convention (lowercase dotted, e.g. `machines.edit`).
Consumed by full_permission_catalog(): core permissions plus every
ENABLED plugin's get_permissions(). That catalog backs `flask seed
permissions`, the role-management grid (GET /api/users/permissions),
and API-token scope validation. Plugin install/enable also seeds the
plugin's own permissions idempotently.
Disabled plugins are skipped by the catalog, so their permissions are
no longer offered for new scope grants or new role assignments. The
Permission ROWS already in the database are NOT deleted, so roles that
already reference them keep working until an admin edits the role. A
broken plugin is isolated in prod and re-raised in dev/test.
Return [] (the default) if the plugin needs no permissions.
"""
return []
def get_reports(self) -> List[Dict]: def get_reports(self) -> List[Dict]:
""" """
Return report card definitions for the Reports hub. Return report card definitions for the Reports hub.

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(): def test_baseplugin_has_frontend_contribution_hooks():
"""The four ADR-010 frontend-contribution hooks are on the contract (0.7.0).""" """The four ADR-010 frontend-contribution hooks are on the contract (0.7.0)."""
for hook in ('get_settings_cards', 'get_asset_panels', for hook in ('get_settings_cards', 'get_asset_panels',