# -*- coding: utf-8 -*-
#
# Univention Management Console
# next generation of UMC modules
#
# Copyright 2011-2022 Univention GmbH
#
# https://www.univention.de/
#
# All rights reserved.
#
# The source code of this program is made available
# under the terms of the GNU Affero General Public License version 3
# (GNU AGPL V3) as published by the Free Software Foundation.
#
# Binary versions of this program provided by Univention to you as
# well as other copyrighted, protected or trademarked materials like
# Logos, graphics, fonts, specific documentations and configurations,
# cryptographic keys etc. are subject to a license agreement between
# you and Univention and not subject to the GNU AGPL V3.
#
# In the case you use this program under the terms of the GNU AGPL V3,
# the program is provided in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public
# License with the Debian GNU/Linux or Univention distribution in file
# /usr/share/common-licenses/AGPL-3; if not, see
# <https://www.gnu.org/licenses/>.
"""
.. _sec-module-definitions:
Module definitions
==================
The UMC server does not load the python modules to get the details about
the modules name, description and functionality. Therefore each UMC
module must provide an XML file containing this kind of information.
The following example defines a module with the id `udm`:
.. code-block:: xml
<?xml version="1.0" encoding="UTF-8"?>
<umc version="2.0">
<module id="udm" icon="udm-module" version="1.0" python="3">
<name>Univention Directory Manager</name>
<description>Manages all UDM modules</description>
<flavor icon="udm-users" id="users/user">
<name>Users</name>
<description>Managing users</description>
</flavor>
<categories>
<category name="domain" />
</categories>
<requiredCommands>
<requiredCommand name="udm/query" />
</requiredCommands>
<command name="udm/query" function="query" />
<command name="udm/containers" function="containers" />
</module>
</umc>
The *module* tag defines the basic details of a UMC module
id
This identifier must be unique among the modules of an UMC server. Other
files may extend the definition of a module by adding more flavors
or categories.
icon
The value of this attribute defines an identifier for the icon that
should be used for the module. Details for installing icons can be
found in the section :ref:`chapter-packaging`
The child elements *name* and *description* define the English human
readable name and description of the module. For other translations the
build tools will create translation files. Details can be found in the
section :ref:`chapter-packaging`.
This example defines a so called flavor. A flavor defines a new name,
description and icon for the same UMC module. This can be used to show
several"virtual" modules in the overview of the web frontend. Additionally the flavor is passed to the UMC server with each request i.e. the UMC module has the possibility to act differently for a specific flavor.
As the next element *categories* is defined in the example. The child
elements *category* set the categories wthin the overview where the
module should be shown. Each module can be more than one category. The
attribute name is to identify the category internally. The UMC server
brings a set of pre-defined categories:
favorites
This category is intended to be filled by the user herself.
system
Tools manipulating the system itself (e.g. software installation)
should go in here.
At the end of the definition file a list of commands is specified. The
UMC server only passes commands to a UMC module that are defined. A
command definition has two attributes:
name
is the name of the command that is passed to the UMC module. Within
the UMCP message it is the first argument after the UMCP COMMAND.
function
defines the method to be invoked within the python module when the
command is called.
keywords
defined keywords for the module to ensure searchability
The translations are stored in extra po files that are generated by the
UMC build tools.
"""
import copy
import os
import sys
import re
import xml.parsers.expat
import xml.etree.cElementTree as ET
from .tools import JSON_Object, JSON_List
from .log import RESOURCES
from .config import ucr
KEYWORD_PATTERN = re.compile(r'\s*,\s*')
[docs]class Command(JSON_Object):
'''Represents a UMCP command handled by a module'''
SEPARATOR = '/'
def __init__(self, name='', method=None, allow_anonymous=False):
self.name = name
if method:
self.method = method
else:
self.method = self.name.replace(Command.SEPARATOR, '_')
self.allow_anonymous = allow_anonymous
[docs] def fromJSON(self, json):
for attr in ('name', 'method'):
setattr(self, attr, json[attr])
setattr(self, 'allow_anonymous', json.get('allow_anonymous', False))
[docs]class Flavor(JSON_Object):
'''Defines a flavor of a module. This provides another name and icon
in the overview and may influence the behavior of the module.'''
def __init__(self, id='', icon='', name='', description='', overwrites=None, deactivated=False, priority=-1, translationId=None, keywords=None, categories=None, required_commands=None, version=None, hidden=False):
self.id = id
self.name = name
self.description = description
self.icon = icon
self.overwrites = overwrites or []
self.keywords = keywords or []
self.deactivated = deactivated
self.priority = priority
self.translationId = translationId
self.categories = categories or []
self.required_commands = required_commands or []
self.version = version
self.hidden = hidden
[docs] def merge(self, other):
self.id = self.id or other.id
self.version = self.version or other.version
self.name = self.name or other.name
self.description = self.description or other.description
self.icon = self.icon or other.icon
self.overwrites = list(set(self.overwrites + other.overwrites))
self.keywords = list(set(self.keywords + other.keywords))
self.deactivated = self.deactivated or other.deactivated
self.priority = self.priority or other.priority
self.translationId = self.translationId or other.translationId
self.categories = list(set(self.categories + other.categories))
self.required_commands = list(set(self.required_commands + other.required_commands))
self.hidden = self.hidden or other.hidden
def __repr__(self):
return '<%s %r>' % (type(self).__name__, self.json())
[docs]class Module(JSON_Object):
'''Represents a command attribute'''
def __init__(self, id='', name='', url='', description='', icon='', categories=None, flavors=None, commands=None, priority=-1, keywords=None, translationId=None, required_commands=None, version=None):
self.id = id
self.name = name
self.url = url
self.description = description
self.keywords = keywords or []
self.icon = icon
self.priority = priority
self.flavors = JSON_List()
self.translationId = translationId
self.required_commands = required_commands or []
self.version = version
if flavors is not None:
self.append_flavors(flavors)
if categories is None:
self.categories = JSON_List()
else:
self.categories = categories
if commands is None:
self.commands = JSON_List()
else:
self.commands = commands
[docs] def fromJSON(self, json):
if isinstance(json, dict):
for attr in ('id', 'name', 'description', 'icon', 'categories', 'keywords'):
setattr(self, attr, json[attr])
commands = json['commands']
else:
commands = json
for cmd in commands:
command = Command()
command.fromJSON(cmd)
self.commands.append(command)
[docs] def append_flavors(self, flavors):
for flavor in flavors:
# remove duplicated flavors
if flavor.id not in [iflavor.id for iflavor in self.flavors] or flavor.deactivated:
self.flavors.append(flavor)
else:
RESOURCES.warn('Duplicated flavor for module %s: %s' % (self.id, flavor.id))
[docs] def merge_flavors(self, other_flavors):
for other_flavor in other_flavors:
try: # merge other_flavor into self_flavor
self_flavor = [iflavor for iflavor in self.flavors if iflavor.id == other_flavor.id][0]
self_flavor.merge(other_flavor)
except IndexError: # add if other_flavor does not exist
RESOURCES.info('Add flavor: %s' % other_flavor.name)
self.flavors.append(other_flavor)
[docs] def merge(self, other):
''' merge another Module object into current one '''
if not self.name:
self.name = other.name
if not self.icon:
self.icon = other.icon
if not self.description:
self.description = other.description
self.version = self.version or other.version
self.keywords = list(set(self.keywords + other.keywords))
self.merge_flavors(other.flavors)
self.categories = JSON_List(set(self.categories + other.categories))
self.commands = JSON_List(set(self.commands + other.commands))
self.required_commands = JSON_List(set(self.required_commands + other.required_commands))
def __repr__(self):
return '<%s %r>' % (type(self).__name__, self.json())
[docs]class Link(Module):
pass
[docs]class XML_Definition(ET.ElementTree):
'''container for the interface description of a module'''
def __init__(self, root=None, filename=None):
ET.ElementTree.__init__(self, element=root, file=filename)
self.root = self.getroot()
@property
def name(self):
return self.findtext('name')
@property
def version(self):
return self.root.findtext('version')
@property
def url(self):
return self.findtext('url')
@property
def description(self):
return self.findtext('description')
@property
def keywords(self):
return KEYWORD_PATTERN.split(self.findtext('keywords', '')) + [self.name]
@property
def id(self):
return self.root.get('id')
@property
def priority(self):
try:
return float(self.root.get('priority', -1))
except ValueError:
RESOURCES.warn('No valid number type for property "priority": %s' % self.root.get('priority'))
return None
@property
def translationId(self):
return self.root.get('translationId', '')
@property
def notifier(self):
return self.root.get('notifier')
@property
def python_version(self):
try:
return int(float(self.root.get('python', 2)))
except ValueError:
return 2
@property
def icon(self):
return self.root.get('icon')
@property
def deactivated(self):
return self.root.get('deactivated', 'no').lower() in ('yes', 'true', '1')
@property
def flavors(self):
'''Retrieve list of flavor objects'''
for elem in self.findall('flavor'):
name = elem.findtext('name')
priority = None
try:
priority = float(elem.get('priority', -1))
except ValueError:
RESOURCES.warn('No valid number type for property "priority": %s' % elem.get('priority'))
categories = [cat.get('name') for cat in elem.findall('categories/category')]
# a empty <categories/> causes the module to be hidden! while a not existing <category> element causes that the categories from the module are used
hidden = elem.find('categories') is not None and not categories
yield Flavor(
id=elem.get('id'),
icon=elem.get('icon'),
name=name,
overwrites=elem.get('overwrites', '').split(','),
deactivated=(elem.get('deactivated', 'no').lower() in ('yes', 'true', '1')),
translationId=self.translationId,
description=elem.findtext('description'),
keywords=re.split(KEYWORD_PATTERN, elem.findtext('keywords', '')) + [name],
priority=priority,
categories=categories,
required_commands=[cmd.get('name') for cmd in elem.findall('requiredCommands/requiredCommand')],
version=self.version,
hidden=hidden,
)
@property
def categories(self):
return [elem.get('name') for elem in self.findall('categories/category')]
[docs] def commands(self):
'''Generator to iterate over the commands'''
for command in self.findall('command'):
yield command.get('name')
[docs] def get_module(self):
cls = {
'link': Link,
'module': Module
}.get(self.root.tag, Module)
return cls(
self.id, self.name, self.url, self.description, self.icon, self.categories, self.flavors,
priority=self.priority,
keywords=self.keywords,
translationId=self.translationId,
required_commands=[cat.get('name') for cat in self.findall('requiredCommands/requiredCommand')],
version=self.version,
)
[docs] def get_flavor(self, name):
'''Retrieves details of a flavor'''
for flavor in self.flavors:
if flavor.name == name:
return flavor
[docs] def get_command(self, name):
'''Retrieves details of a command'''
for command in self.findall('command'):
if command.get('name') == name:
return Command(name, command.get('function'), command.get('allow_anonymous', '0').lower() in ('yes', 'true', '1'))
def __bool__(self):
module = self.find('module')
return module is not None and len(module) != 0
__nonzero__ = __bool__
def __repr__(self):
return '<XML_Definition %s (%r)>' % (self.id, self.name)
_manager = None
[docs]class Manager(dict):
'''Manager of all available modules'''
DIRECTORY = os.path.join(sys.prefix, 'share/univention-management-console/modules')
def __init__(self):
dict.__init__(self)
[docs] def modules(self):
'''Returns list of module names'''
return list(self.keys())
[docs] def load(self):
'''Loads the list of available modules. As the list is cleared
before, the method can also be used for reloading'''
RESOURCES.info('Loading modules ...')
self.clear()
for filename in os.listdir(Manager.DIRECTORY):
if not filename.endswith('.xml'):
continue
try:
parsed_xml = ET.parse(os.path.join(Manager.DIRECTORY, filename))
RESOURCES.info('Loaded module %s' % filename)
for mod_tree in parsed_xml.getroot():
mod = XML_Definition(root=mod_tree)
if mod.deactivated:
RESOURCES.info('Module is deactivated: %s' % filename)
continue
# save list of definitions in self
self.setdefault(mod.id, []).append(mod)
except (xml.parsers.expat.ExpatError, ET.ParseError) as exc:
RESOURCES.warn('Failed to load module %s: %s' % (filename, exc))
continue
[docs] def is_command_allowed(self, acls, command, hostname=None, options={}, flavor=None):
for module_xmls in self.values():
for module_xml in module_xmls:
cmd = module_xml.get_command(command)
if cmd and cmd.allow_anonymous:
return True
return acls.is_command_allowed(command, hostname, options, flavor)
[docs] def permitted_commands(self, hostname, acls):
'''Retrieves a list of all modules and commands available
according to the ACLs (instance of LDAP_ACLs)
{ id : Module, ... }
'''
RESOURCES.info('Retrieving list of permitted commands')
modules = {}
for module_id in self:
# get first Module and merge all subsequent Module objects into it
mod = None
for module_xml in self[module_id]:
nextmod = module_xml.get_module()
if mod:
mod.merge(nextmod)
else:
mod = nextmod
if ucr.is_true('umc/module/%s/disabled' % (module_id)):
RESOURCES.info('module %s is deactivated by UCR' % (module_id))
continue
if isinstance(mod, Link):
if mod.url:
modules[module_id] = mod
else:
RESOURCES.info('invalid link %s: no url element' % (module_id))
continue
if not mod.flavors:
flavors = [Flavor(id=None, required_commands=mod.required_commands)]
else:
flavors = copy.copy(mod.flavors)
deactivated_flavors = set()
for flavor in flavors:
if ucr.is_true('umc/module/%s/%s/disabled' % (module_id, flavor.id)):
RESOURCES.info('flavor %s (module=%s) is deactivated by UCR' % (flavor.id, module_id))
# flavor is deactivated by UCR variable
flavor.deactivated = True
RESOURCES.info('mod=%r flavor=%r deactivated=%r hidden=%r' % (module_id, flavor.id, flavor.deactivated, flavor.hidden))
if flavor.deactivated:
deactivated_flavors.add(flavor.id)
continue
required_commands = [module_xml.get_command(command) for module_xml in self[module_id] for command in module_xml.commands() if command in flavor.required_commands]
apply_function = all
if not required_commands:
# backwards compatibility. if any of the commands defined in the module is allowed this module is visible
apply_function = any
required_commands = [module_xml.get_command(command) for module_xml in self[module_id] for command in module_xml.commands()]
if apply_function(cmd.allow_anonymous or acls.is_command_allowed(cmd.name, hostname, flavor=flavor.id) for cmd in required_commands):
modules.setdefault(module_id, mod)
all_commands = set(module_xml.get_command(command) for module_xml in self[module_id] for command in module_xml.commands())
modules[module_id].commands = JSON_List(set(modules[module_id].commands) | all_commands)
elif mod.flavors:
# if there is not one command allowed with this flavor
# it should not be shown in the overview
mod.flavors.remove(flavor)
mod.flavors = JSON_List(f for f in mod.flavors if f.id not in deactivated_flavors)
overwrites = set()
for flavor in mod.flavors:
overwrites.update(flavor.overwrites)
mod.flavors = JSON_List(f for f in mod.flavors if f.id not in overwrites)
return modules
[docs] def module_providing(self, modules, command):
'''Searches a dictionary of modules (as returned by
permitted_commands) for the given command. If found, the id of
the module is returned, otherwise None'''
RESOURCES.info('Searching for module providing command %s' % command)
for module_id in modules:
for cmd in modules[module_id].commands:
if cmd.name == command:
RESOURCES.info('Found module %s' % module_id)
return module_id
RESOURCES.info('No module provides %s' % command)
return None
if __name__ == '__main__':
mgr = Manager()