Add the get_permissions plugin hook (contract 0.10.0)
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:
@@ -27,7 +27,12 @@ from .plugins import plugin_manager
|
||||
# (resolve_dualpath_pairs, dualpath_single_machine_enabled), consumed by the
|
||||
# machines plugin list/detail to collapse dual-bay pairs. Two additive names,
|
||||
# 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
|
||||
# plugin-contract version above are distinct series with independent
|
||||
|
||||
@@ -5,7 +5,8 @@ from flask_jwt_extended import jwt_required, current_user
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
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.authz import require_role
|
||||
|
||||
@@ -179,29 +180,29 @@ def delete_user(userid: int):
|
||||
@users_bp.route('/permissions', methods=['GET'])
|
||||
@jwt_required()
|
||||
def list_permissions():
|
||||
"""List all permissions grouped by category."""
|
||||
permissions = Permission.query.order_by(Permission.category, Permission.name).all()
|
||||
"""List assignable permissions grouped by category.
|
||||
|
||||
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 = {}
|
||||
for p in permissions:
|
||||
if p.category not in grouped:
|
||||
grouped[p.category] = []
|
||||
grouped[p.category].append({
|
||||
'permissionid': p.permissionid,
|
||||
'name': p.name,
|
||||
'description': p.description
|
||||
})
|
||||
flat = []
|
||||
for name, description, category in catalog:
|
||||
entry = {
|
||||
'permissionid': idbyname.get(name),
|
||||
'name': name,
|
||||
'description': description,
|
||||
}
|
||||
grouped.setdefault(category, []).append(entry)
|
||||
flat.append({**entry, 'category': category})
|
||||
|
||||
return success_response({
|
||||
'permissions': [{
|
||||
'permissionid': p.permissionid,
|
||||
'name': p.name,
|
||||
'description': p.description,
|
||||
'category': p.category
|
||||
} for p in permissions],
|
||||
'grouped': grouped
|
||||
})
|
||||
return success_response({'permissions': flat, 'grouped': grouped})
|
||||
|
||||
|
||||
# Roles endpoints
|
||||
|
||||
@@ -11,7 +11,7 @@ from .location import Location, LocationType
|
||||
from .operatingsystem import OperatingSystem
|
||||
from .relationship import AssetRelationship, RelationshipType, RelationshipTypePropagation
|
||||
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 .supportteam import SupportTeam, SupportTeamContact
|
||||
from .setting import Setting
|
||||
@@ -49,6 +49,7 @@ __all__ = [
|
||||
'User',
|
||||
'Role',
|
||||
'Permission',
|
||||
'full_permission_catalog',
|
||||
# Applications
|
||||
'Application',
|
||||
'AppVersion',
|
||||
|
||||
@@ -100,9 +100,12 @@ class ApiToken(BaseModel):
|
||||
|
||||
@staticmethod
|
||||
def unknown_scope_names(names) -> list:
|
||||
"""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}
|
||||
"""Return the subset of names that are not in the permission catalog.
|
||||
|
||||
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]
|
||||
|
||||
def to_dict(self, include_owner: bool = False) -> dict:
|
||||
|
||||
@@ -33,63 +33,22 @@ class Permission(db.Model):
|
||||
description = db.Column(db.String(255))
|
||||
category = db.Column(db.String(50), default='general') # For grouping in UI
|
||||
|
||||
# Predefined permissions
|
||||
PERMISSIONS = [
|
||||
# Core permission catalog. This is CORE ONLY - permissions for a plugin
|
||||
# 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.view', 'View assets', 'assets'),
|
||||
('assets.create', 'Create assets', 'assets'),
|
||||
('assets.edit', 'Edit 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.view', 'View applications', 'applications'),
|
||||
('applications.create', 'Create applications', 'applications'),
|
||||
('applications.edit', 'Edit 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.view', 'View reports', 'reports'),
|
||||
('reports.export', 'Export reports', 'reports'),
|
||||
@@ -117,16 +76,78 @@ class Permission(db.Model):
|
||||
return f"<Permission {self.name}>"
|
||||
|
||||
@classmethod
|
||||
def seed(cls):
|
||||
"""Seed predefined permissions."""
|
||||
def seed_entries(cls, entries):
|
||||
"""Idempotently create Permission rows from (name, desc, category)
|
||||
tuples (or equivalent dicts). Returns how many were created."""
|
||||
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():
|
||||
perm = cls(name=name, description=description, category=category)
|
||||
db.session.add(perm)
|
||||
created += 1
|
||||
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):
|
||||
"""User role model."""
|
||||
@@ -156,7 +177,7 @@ class Role(BaseModel):
|
||||
def getpermissionnames(self) -> list:
|
||||
"""Get list of permission names."""
|
||||
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]
|
||||
|
||||
|
||||
@@ -209,7 +230,7 @@ class User(BaseModel):
|
||||
def getpermissions(self) -> list:
|
||||
"""Get list of all permission names from all roles."""
|
||||
if self.hasrole('admin'):
|
||||
return [p[0] for p in Permission.PERMISSIONS]
|
||||
return [p[0] for p in full_permission_catalog()]
|
||||
|
||||
perms = set()
|
||||
for role in self.roles:
|
||||
|
||||
@@ -220,11 +220,35 @@ class PluginManager:
|
||||
plugin = self.loader.load_plugin(name, self._app, self._db)
|
||||
if plugin:
|
||||
self._register_plugin_components(plugin)
|
||||
self._seed_plugin_permissions(plugin)
|
||||
plugin.on_install(self._app)
|
||||
|
||||
logger.info(f"Installed plugin: {name} v{manifest_version}")
|
||||
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:
|
||||
"""
|
||||
Uninstall a plugin.
|
||||
@@ -305,6 +329,7 @@ class PluginManager:
|
||||
try:
|
||||
plugin = self.loader.load_plugin(name, self._app, self._db)
|
||||
if plugin:
|
||||
self._seed_plugin_permissions(plugin)
|
||||
plugin.on_enable(self._app)
|
||||
except Exception:
|
||||
logger.exception(f"on_enable hook failed for plugin {name}")
|
||||
|
||||
@@ -209,6 +209,40 @@ class BasePlugin(ABC):
|
||||
"""
|
||||
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]:
|
||||
"""
|
||||
Return report card definitions for the Reports hub.
|
||||
|
||||
Reference in New Issue
Block a user