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

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