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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user