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

@@ -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

View File

@@ -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

View File

@@ -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',

View File

@@ -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:

View File

@@ -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:

View File

@@ -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}")

View File

@@ -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.