Refactoring: Moved check parameters from unsorted.py to dedicated modules (CMK-1393)
[check_mk.git] / cmk / gui / userdb.py
blob19e62e2f29518e15f8f95f56747c03259f762c71
1 #!/usr/bin/python
2 # -*- encoding: utf-8; py-indent-offset: 4 -*-
3 # +------------------------------------------------------------------+
4 # | ____ _ _ __ __ _ __ |
5 # | / ___| |__ ___ ___| | __ | \/ | |/ / |
6 # | | | | '_ \ / _ \/ __| |/ / | |\/| | ' / |
7 # | | |___| | | | __/ (__| < | | | | . \ |
8 # | \____|_| |_|\___|\___|_|\_\___|_| |_|_|\_\ |
9 # | |
10 # | Copyright Mathias Kettner 2014 mk@mathias-kettner.de |
11 # +------------------------------------------------------------------+
13 # This file is part of Check_MK.
14 # The official homepage is at http://mathias-kettner.de/check_mk.
16 # check_mk is free software; you can redistribute it and/or modify it
17 # under the terms of the GNU General Public License as published by
18 # the Free Software Foundation in version 2. check_mk is distributed
19 # in the hope that it will be useful, but WITHOUT ANY WARRANTY; with-
20 # out even the implied warranty of MERCHANTABILITY or FITNESS FOR A
21 # PARTICULAR PURPOSE. See the GNU General Public License for more de-
22 # tails. You should have received a copy of the GNU General Public
23 # License along with GNU Make; see the file COPYING. If not, write
24 # to the Free Software Foundation, Inc., 51 Franklin St, Fifth Floor,
25 # Boston, MA 02110-1301 USA.
27 # TODO: Rework connection management and multiplexing
29 import time
30 import os
31 import traceback
32 import copy
34 import pathlib2 as pathlib
35 import six
37 import cmk.utils
38 import cmk.utils.paths
39 import cmk.utils.store as store
41 import cmk.gui.pages
42 import cmk.gui.utils as utils
43 import cmk.gui.config as config
44 import cmk.gui.hooks as hooks
45 import cmk.gui.background_job as background_job
46 import cmk.gui.gui_background_job as gui_background_job
47 from cmk.gui.exceptions import MKUserError, MKInternalError
48 from cmk.gui.log import logger
49 from cmk.gui.valuespec import (
50 DualListChoice,
51 TextAscii,
52 DropdownChoice,
54 import cmk.gui.i18n
55 from cmk.gui.i18n import _
56 from cmk.gui.globals import html
57 import cmk.gui.plugins.userdb
58 from cmk.gui.plugins.userdb.htpasswd import Htpasswd
60 from cmk.gui.plugins.userdb.utils import (
61 user_attribute_registry,
62 user_connector_registry,
65 # Datastructures and functions needed before plugins can be loaded
66 loaded_with_language = False
68 # Custom user attributes
69 user_attributes = {}
70 builtin_user_attribute_names = []
72 # Connection configuration
73 connection_dict = {}
74 # Connection object dictionary
75 g_connections = {}
76 auth_logger = logger.getChild("auth")
79 # Load all userdb plugins
80 def load_plugins(force):
81 update_config_based_user_attributes()
83 connection_dict.clear()
84 for connection in config.user_connections:
85 connection_dict[connection['id']] = connection
87 # Cleanup eventual still open connections
88 if g_connections:
89 g_connections.clear()
91 global loaded_with_language
92 if loaded_with_language == cmk.gui.i18n.get_current_language() and not force:
93 return
95 utils.load_web_plugins("userdb", globals())
97 # This must be set after plugin loading to make broken plugins raise
98 # exceptions all the time and not only the first time (when the plugins
99 # are loaded).
100 loaded_with_language = cmk.gui.i18n.get_current_language()
103 # Cleans up at the end of a request: Cleanup eventual open connections
104 def finalize():
105 if g_connections:
106 g_connections.clear()
109 # Returns a list of two part tuples where the first element is the unique
110 # connection id and the second element the connector specification dict
111 def get_connections(only_enabled=False):
112 connections = []
113 for connector_id, connector_class in user_connector_registry.items():
114 if connector_id == 'htpasswd':
115 # htpasswd connector is enabled by default and always executed first
116 connections.insert(0, ('htpasswd', connector_class({})))
117 else:
118 for connection_config in config.user_connections:
119 if only_enabled and connection_config.get('disabled'):
120 continue
122 connection = connector_class(connection_config)
124 if only_enabled and not connection.is_enabled():
125 continue
127 connections.append((connection_config['id'], connection))
128 return connections
131 def active_connections():
132 return get_connections(only_enabled=True)
135 def connection_choices():
136 return sorted([(cid, "%s (%s)" % (cid, c.type()))
137 for cid, c in get_connections(only_enabled=False)
138 if c.type() == "ldap"],
139 key=lambda x_y: x_y[1])
142 # When at least one LDAP connection is defined and active a sync is possible
143 def sync_possible():
144 return any(connection.type() == "ldap" for _connection_id, connection in active_connections())
147 def cleanup_connection_id(connection_id):
148 if connection_id is None:
149 connection_id = 'htpasswd'
151 # Old Check_MK used a static "ldap" connector id for all LDAP users.
152 # Since Check_MK now supports multiple LDAP connections, the ID has
153 # been changed to "default". But only transform this when there is
154 # no connection existing with the id LDAP.
155 if connection_id == 'ldap' and not get_connection('ldap'):
156 connection_id = 'default'
158 return connection_id
161 # Returns the connection object of the requested connection id. This function
162 # maintains a cache that for a single connection_id only one object per request
163 # is created.
164 def get_connection(connection_id):
165 if connection_id in g_connections:
166 return g_connections[connection_id]
168 connection = dict(get_connections()).get(connection_id)
170 if connection:
171 g_connections[connection_id] = connection
173 return connection
176 # Returns a list of connection specific locked attributes
177 def locked_attributes(connection_id):
178 return get_attributes(connection_id, "locked_attributes")
181 # Returns a list of connection specific multisite attributes
182 def multisite_attributes(connection_id):
183 return get_attributes(connection_id, "multisite_attributes")
186 # Returns a list of connection specific non contact attributes
187 def non_contact_attributes(connection_id):
188 return get_attributes(connection_id, "non_contact_attributes")
191 def get_attributes(connection_id, what):
192 connection = get_connection(connection_id)
193 if connection:
194 return getattr(connection, what)()
195 return []
198 def new_user_template(connection_id):
199 new_user = {
200 'serial': 0,
201 'connector': connection_id,
204 # Apply the default user profile
205 new_user.update(config.default_user_profile)
206 return new_user
209 def create_non_existing_user(connection_id, username):
210 if user_exists(username):
211 return # User exists. Nothing to do...
213 users = load_users(lock=True)
214 users[username] = new_user_template(connection_id)
215 save_users(users)
217 # Call the sync function for this new user
218 connection = get_connection(connection_id)
219 try:
220 connection.do_sync(add_to_changelog=False, only_username=username)
221 except cmk.gui.plugins.userdb.ldap_connector.MKLDAPException as e:
222 show_exception(connection_id, _("Error during sync"), e, debug=config.debug)
223 except Exception as e:
224 show_exception(connection_id, _("Error during sync"), e)
227 def is_customer_user_allowed_to_login(user_id):
228 if not cmk.is_managed_edition():
229 return True
231 import cmk.gui.cme.managed as managed
232 user = config.LoggedInUser(user_id)
233 customer_id = managed.get_customer_id(user.attributes)
235 if managed.is_global(customer_id):
236 return True
238 return managed.is_current_customer(customer_id)
241 # This function is called very often during regular page loads so it has to be efficient
242 # even when having a lot of users.
244 # When using the multisite authentication with just by WATO created users it would be
245 # easy, but we also need to deal with users which are only existant in the htpasswd
246 # file and don't have a profile directory yet.
247 def user_exists(username):
248 if _user_exists_according_to_profile(username):
249 return True
251 return Htpasswd(pathlib.Path(cmk.utils.paths.htpasswd_file)).exists(username)
254 def _user_exists_according_to_profile(username):
255 base_path = config.config_dir + "/" + username.encode("utf-8") + "/"
256 return os.path.exists(base_path + "transids.mk") \
257 or os.path.exists(base_path + "serial.mk")
260 def user_locked(username):
261 users = load_users()
262 return users[username].get('locked', False)
265 def login_timed_out(username, last_activity):
266 idle_timeout = load_custom_attr(username, "idle_timeout", convert_idle_timeout, None)
267 if idle_timeout is None:
268 idle_timeout = config.user_idle_timeout
270 if idle_timeout in [None, False]:
271 return False # no timeout activated at all
273 timed_out = (time.time() - last_activity) > idle_timeout
275 if timed_out:
276 auth_logger.debug("%s login timed out (Inactive for %d seconds)" %
277 (username, time.time() - last_activity))
279 return timed_out
282 def update_user_access_time(username):
283 if not config.save_user_access_times:
284 return
285 save_custom_attr(username, 'last_seen', repr(time.time()))
288 def on_succeeded_login(username):
289 num_failed_logins = load_custom_attr(username, 'num_failed_logins', utils.saveint)
290 if num_failed_logins is not None and num_failed_logins != 0:
291 save_custom_attr(username, 'num_failed_logins', '0')
293 update_user_access_time(username)
296 # userdb.need_to_change_pw returns either False or the reason description why the
297 # password needs to be changed
298 def need_to_change_pw(username):
299 if load_custom_attr(username, 'enforce_pw_change', utils.saveint) == 1:
300 return 'enforced'
302 last_pw_change = load_custom_attr(username, 'last_pw_change', utils.saveint)
303 max_pw_age = config.password_policy.get('max_age')
304 if max_pw_age:
305 if not last_pw_change:
306 # The age of the password is unknown. Assume the user has just set
307 # the password to have the first access after enabling password aging
308 # as starting point for the password period. This bewares all users
309 # from needing to set a new password after enabling aging.
310 save_custom_attr(username, 'last_pw_change', str(int(time.time())))
311 return False
312 elif time.time() - last_pw_change > max_pw_age:
313 return 'expired'
314 return False
317 def on_failed_login(username):
318 users = load_users(lock=True)
319 if username in users:
320 if "num_failed_logins" in users[username]:
321 users[username]["num_failed_logins"] += 1
322 else:
323 users[username]["num_failed_logins"] = 1
325 if config.lock_on_logon_failures:
326 if users[username]["num_failed_logins"] >= config.lock_on_logon_failures:
327 users[username]["locked"] = True
329 save_users(users)
332 root_dir = cmk.utils.paths.check_mk_config_dir + "/wato/"
333 multisite_dir = cmk.utils.paths.default_config_dir + "/multisite.d/wato/"
336 # Old vs:
337 #ListChoice(
338 # title = _('Automatic User Synchronization'),
339 # help = _('By default the users are synchronized automatically in several situations. '
340 # 'The sync is started when opening the "Users" page in configuration and '
341 # 'during each page rendering. Each connector can then specify if it wants to perform '
342 # 'any actions. For example the LDAP connector will start the sync once the cached user '
343 # 'information are too old.'),
344 # default_value = [ 'wato_users', 'page', 'wato_pre_activate_changes', 'wato_snapshot_pushed' ],
345 # choices = [
346 # ('page', _('During regular page processing')),
347 # ('wato_users', _('When opening the users\' configuration page')),
348 # ('wato_pre_activate_changes', _('Before activating the changed configuration')),
349 # ('wato_snapshot_pushed', _('On a remote site, when it receives a new configuration')),
350 # ],
351 # allow_empty = True,
353 def transform_userdb_automatic_sync(val):
354 if val == []:
355 # legacy compat - disabled
356 return None
358 elif isinstance(val, list) and val:
359 # legacy compat - all connections
360 return "all"
362 return val
365 class UserSelection(DropdownChoice):
366 """Dropdown for choosing a multisite user"""
368 def __init__(self, **kwargs):
369 only_contacts = kwargs.get("only_contacts", False)
370 kwargs["choices"] = self._generate_wato_users_elements_function(
371 kwargs.get("none"), only_contacts=only_contacts)
372 kwargs["invalid_choice"] = "complain" # handle vanished users correctly!
373 DropdownChoice.__init__(self, **kwargs)
375 def _generate_wato_users_elements_function(self, none_value, only_contacts=False):
376 def get_wato_users(nv):
377 users = load_users()
378 elements = [(name, "%s - %s" % (name, us.get("alias", name)))
379 for (name, us) in users.items()
380 if (not only_contacts or us.get("contactgroups"))]
381 elements.sort()
382 if nv is not None:
383 elements = [(None, none_value)] + elements
384 return elements
386 return lambda: get_wato_users(none_value)
388 def value_to_text(self, value):
389 text = DropdownChoice.value_to_text(self, value)
390 return text.split(" - ")[-1]
394 # .--User Session--------------------------------------------------------.
395 # | _ _ ____ _ |
396 # | | | | |___ ___ _ __ / ___| ___ ___ ___(_) ___ _ __ |
397 # | | | | / __|/ _ \ '__| \___ \ / _ \/ __/ __| |/ _ \| '_ \ |
398 # | | |_| \__ \ __/ | ___) | __/\__ \__ \ | (_) | | | | |
399 # | \___/|___/\___|_| |____/ \___||___/___/_|\___/|_| |_| |
400 # | |
401 # +----------------------------------------------------------------------+
402 # | When single users sessions are activated, a user an only login once |
403 # | a time. In case a user tries to login a second time, an error is |
404 # | shown to the later login. |
405 # | |
406 # | To make this feature possible a session ID is computed during login, |
407 # | saved in the users cookie and stored in the user profile together |
408 # | with the current time as "last activity" timestamp. This timestamp |
409 # | is updated during each user activity in the GUI. |
410 # | |
411 # | Once a user logs out or the "last activity" is older than the |
412 # | configured session timeout, the session is invalidated. The user |
413 # | can then login again from the same client or another one. |
414 # '----------------------------------------------------------------------'
417 def is_valid_user_session(username, session_id):
418 if config.single_user_session is None:
419 return True # No login session limitation enabled, no validation
421 session_info = load_session_info(username)
422 if session_info is None:
423 return False # no session active
424 else:
425 active_session_id, last_activity = session_info
427 if session_id == active_session_id:
428 return True # Current session. Fine.
430 auth_logger.debug("%s session_id not valid (timed out?) (Inactive for %d seconds)" %
431 (username, time.time() - last_activity))
433 return False
436 def ensure_user_can_init_session(username):
437 if config.single_user_session is None:
438 return True # No login session limitation enabled, no validation
440 session_timeout = config.single_user_session
442 session_info = load_session_info(username)
443 if session_info is None:
444 return True # No session active
446 last_activity = session_info[1]
447 if (time.time() - last_activity) > session_timeout:
448 return True # Former active session timed out
450 auth_logger.debug("%s another session is active (inactive for: %d seconds)" %
451 (username, time.time() - last_activity))
453 raise MKUserError(None, _("Another session is active"))
456 # Creates a new user login session (if single user session mode is enabled) and
457 # returns the session_id of the new session.
458 def initialize_session(username):
459 if not config.single_user_session:
460 return ""
462 session_id = create_session_id()
463 save_session_info(username, session_id)
464 return session_id
467 # Creates a random session id for the user and returns it.
468 def create_session_id():
469 return utils.gen_id()
472 # Updates the current session of the user and returns the session_id or only
473 # returns an empty string when single user session mode is not enabled.
474 def refresh_session(username):
475 if not config.single_user_session:
476 return ""
478 session_info = load_session_info(username)
479 if session_info is None:
480 return # Don't refresh. Session is not valid anymore
482 session_id = session_info[0]
483 save_session_info(username, session_id)
486 def invalidate_session(username):
487 remove_custom_attr(username, "session_info")
490 # Saves the current session_id and the current time (last activity)
491 def save_session_info(username, session_id):
492 save_custom_attr(username, "session_info", "%s|%s" % (session_id, int(time.time())))
495 # Returns either None (when no session_id available) or a two element
496 # tuple where the first element is the sesssion_id and the second the
497 # timestamp of the last activity.
498 def load_session_info(username):
499 return load_custom_attr(username, "session_info", convert_session_info)
502 def convert_session_info(value):
503 if value == "":
504 return None
506 session_id, last_activity = value.split("|", 1)
507 return session_id, int(last_activity)
511 # .-Users----------------------------------------------------------------.
512 # | _ _ |
513 # | | | | |___ ___ _ __ ___ |
514 # | | | | / __|/ _ \ '__/ __| |
515 # | | |_| \__ \ __/ | \__ \ |
516 # | \___/|___/\___|_| |___/ |
517 # | |
518 # +----------------------------------------------------------------------+
521 class GenericUserAttribute(cmk.gui.plugins.userdb.UserAttribute):
522 def __init__(self, user_editable, show_in_table, add_custom_macro, domain, permission,
523 from_config):
524 super(GenericUserAttribute, self).__init__()
525 self._user_editable = user_editable
526 self._show_in_table = show_in_table
527 self._add_custom_macro = add_custom_macro
528 self._domain = domain
529 self._permission = permission
530 self._from_config = from_config
532 def from_config(self):
533 return self._from_config
535 def user_editable(self):
536 return self._user_editable
538 def permission(self):
539 return self._permission
541 def show_in_table(self):
542 return self._show_in_table
544 def add_custom_macro(self):
545 return self._add_custom_macro
547 def domain(self):
548 return self._domain
551 # TODO: Legacy plugin API. Converts to new internal structure. Drop this with 1.6 or later.
552 def declare_user_attribute(name,
554 user_editable=True,
555 permission=None,
556 show_in_table=False,
557 topic=None,
558 add_custom_macro=False,
559 domain="multisite",
560 from_config=False):
562 # FIXME: The classmethods "name" and "topic" shadow the arguments from the function scope.
563 # Any use off "name" and "topic" inside the class will result in a NameError.
564 attr_name = name
565 attr_topic = topic
567 @user_attribute_registry.register
568 class LegacyUserAttribute(GenericUserAttribute):
569 _name = attr_name
570 _valuespec = vs
571 _topic = attr_topic if attr_topic else 'personal'
573 @classmethod
574 def name(cls):
575 return cls._name
577 @classmethod
578 def valuespec(cls):
579 return cls._valuespec
581 @classmethod
582 def topic(cls):
583 return cls._topic
585 def __init__(self):
586 super(LegacyUserAttribute, self).__init__(
587 user_editable=user_editable,
588 show_in_table=show_in_table,
589 add_custom_macro=add_custom_macro,
590 domain=domain,
591 permission=permission,
592 from_config=from_config,
596 def get_user_attributes():
597 return [(k, v()) for k, v in user_attribute_registry.items()]
600 def load_users(lock=False):
601 filename = root_dir + "contacts.mk"
603 if lock:
604 # Note: the lock will be released on next save_users() call or at
605 # end of page request automatically.
606 store.aquire_lock(filename)
608 if html.is_cached('users'):
609 return html.get_cached('users')
611 # First load monitoring contacts from Check_MK's world. If this is
612 # the first time, then the file will be empty, which is no problem.
613 # Execfile will the simply leave contacts = {} unchanged.
614 contacts = store.load_from_mk_file(filename, "contacts", {})
616 # Now load information about users from the GUI config world
617 filename = multisite_dir + "users.mk"
618 users = store.load_from_mk_file(multisite_dir + "users.mk", "multisite_users", {})
620 # Merge them together. Monitoring users not known to Multisite
621 # will be added later as normal users.
622 result = {}
623 for uid, user in users.items():
624 # Transform user IDs which were stored with a wrong type
625 if isinstance(uid, str):
626 uid = uid.decode("utf-8")
628 profile = contacts.get(uid, {})
629 profile.update(user)
630 result[uid] = profile
632 # Convert non unicode mail addresses
633 if isinstance(profile.get("email"), str):
634 profile["email"] = profile["email"].decode("utf-8")
636 # This loop is only neccessary if someone has edited
637 # contacts.mk manually. But we want to support that as
638 # far as possible.
639 for uid, contact in contacts.items():
640 # Transform user IDs which were stored with a wrong type
641 if isinstance(uid, str):
642 uid = uid.decode("utf-8")
644 if uid not in result:
645 result[uid] = contact
646 result[uid]["roles"] = ["user"]
647 result[uid]["locked"] = True
648 result[uid]["password"] = ""
650 # Passwords are read directly from the apache htpasswd-file.
651 # That way heroes of the command line will still be able to
652 # change passwords with htpasswd. Users *only* appearing
653 # in htpasswd will also be loaded and assigned to the role
654 # they are getting according to the multisite old-style
655 # configuration variables.
657 def readlines(f):
658 try:
659 return file(f)
660 except IOError:
661 return []
663 # FIXME TODO: Consolidate with htpasswd user connector
664 filename = cmk.utils.paths.htpasswd_file
665 for line in readlines(filename):
666 line = line.strip()
667 if ':' in line:
668 uid, password = line.strip().split(":")[:2]
669 uid = uid.decode("utf-8")
670 if password.startswith("!"):
671 locked = True
672 password = password[1:]
673 else:
674 locked = False
675 if uid in result:
676 result[uid]["password"] = password
677 result[uid]["locked"] = locked
678 else:
679 # Create entry if this is an admin user
680 new_user = {
681 "roles": config.roles_of_user(uid),
682 "password": password,
683 "locked": False,
685 result[uid] = new_user
686 # Make sure that the user has an alias
687 result[uid].setdefault("alias", uid)
688 # Other unknown entries will silently be dropped. Sorry...
690 # Now read the serials, only process for existing users
691 serials_file = '%s/auth.serials' % os.path.dirname(cmk.utils.paths.htpasswd_file)
692 for line in readlines(serials_file):
693 line = line.strip()
694 if ':' in line:
695 user_id, serial = line.split(':')[:2]
696 user_id = user_id.decode("utf-8")
697 if user_id in result:
698 result[user_id]['serial'] = utils.saveint(serial)
700 # Now read the user specific files
701 directory = cmk.utils.paths.var_dir + "/web/"
702 for d in os.listdir(directory):
703 if d[0] != '.':
704 uid = d.decode("utf-8")
706 # read special values from own files
707 if uid in result:
708 for attr, conv_func in [
709 ('num_failed_logins', utils.saveint),
710 ('last_pw_change', utils.saveint),
711 ('last_seen', utils.savefloat),
712 ('enforce_pw_change', lambda x: bool(utils.saveint(x))),
713 ('idle_timeout', convert_idle_timeout),
714 ('session_id', convert_session_info),
716 val = load_custom_attr(uid, attr, conv_func)
717 if val is not None:
718 result[uid][attr] = val
720 # read automation secrets and add them to existing
721 # users or create new users automatically
722 try:
723 secret = file(directory + d + "/automation.secret").read().strip()
724 except IOError:
725 secret = None
726 if secret:
727 if uid in result:
728 result[uid]["automation_secret"] = secret
729 else:
730 result[uid] = {
731 "roles": ["guest"],
732 "automation_secret": secret,
735 # populate the users cache
736 html.set_cache('users', result)
738 return result
741 def custom_attr_path(userid, key):
742 return cmk.utils.paths.var_dir + "/web/" + cmk.utils.make_utf8(userid) + "/" + key + ".mk"
745 def load_custom_attr(userid, key, conv_func, default=None):
746 path = custom_attr_path(userid, key)
747 try:
748 return conv_func(file(path).read().strip())
749 except IOError:
750 return default
753 def save_custom_attr(userid, key, val):
754 path = custom_attr_path(userid, key)
755 store.mkdir(os.path.dirname(path))
756 store.save_file(path, '%s\n' % val)
759 def remove_custom_attr(userid, key):
760 try:
761 os.unlink(custom_attr_path(userid, key))
762 except OSError:
763 pass # Ignore non existing files
766 def get_online_user_ids():
767 online_threshold = time.time() - config.user_online_maxage
768 users = []
769 for user_id, user in load_users(lock=False).items():
770 if user.get('last_seen', 0) >= online_threshold:
771 users.append(user_id)
772 return users
775 def split_dict(d, keylist, positive):
776 return dict([(k, v) for (k, v) in d.items() if (k in keylist) == positive])
779 def save_users(profiles):
780 write_contacts_and_users_file(profiles)
782 # Execute user connector save hooks
783 hook_save(profiles)
785 updated_profiles = _add_custom_macro_attributes(profiles)
787 _save_auth_serials(updated_profiles)
788 _save_user_profiles(updated_profiles)
789 _cleanup_old_user_profiles(updated_profiles)
791 # Release the lock to make other threads access possible again asap
792 # This lock is set by load_users() only in the case something is expected
793 # to be written (like during user syncs, wato, ...)
794 release_users_lock()
796 # populate the users cache
797 # TODO: Can we clean this up?
798 html.set_cache('users', updated_profiles)
800 # Call the users_saved hook
801 hooks.call("users-saved", updated_profiles)
804 def release_users_lock():
805 store.release_lock(root_dir + "contacts.mk")
808 # TODO: Isn't this needed only while generating the contacts.mk?
809 # Check this and move it to the right place
810 def _add_custom_macro_attributes(profiles):
811 updated_profiles = copy.deepcopy(profiles)
813 # Add custom macros
814 core_custom_macros = [k for k, o in user_attributes.items() if o.get('add_custom_macro')]
815 for user in updated_profiles.keys():
816 for macro in core_custom_macros:
817 if macro in updated_profiles[user]:
818 updated_profiles[user]['_' + macro] = updated_profiles[user][macro]
820 return updated_profiles
823 # Write user specific files
824 def _save_user_profiles(updated_profiles):
825 non_contact_keys = _non_contact_keys()
826 multisite_keys = _multisite_keys()
828 for user_id, user in updated_profiles.items():
829 user_dir = cmk.utils.paths.var_dir + "/web/" + user_id.encode("utf-8")
830 store.mkdir(user_dir)
832 # authentication secret for local processes
833 auth_file = user_dir + "/automation.secret"
834 if "automation_secret" in user:
835 store.save_file(auth_file, "%s\n" % user["automation_secret"])
836 elif os.path.exists(auth_file):
837 os.unlink(auth_file)
839 # Write out user attributes which are written to dedicated files in the user
840 # profile directory. The primary reason to have separate files, is to reduce
841 # the amount of data to be loaded during regular page processing
842 save_custom_attr(user_id, 'serial', str(user.get('serial', 0)))
843 save_custom_attr(user_id, 'num_failed_logins', str(user.get('num_failed_logins', 0)))
844 save_custom_attr(user_id, 'enforce_pw_change', str(
845 int(user.get('enforce_pw_change', False))))
846 save_custom_attr(user_id, 'last_pw_change', str(
847 user.get('last_pw_change', int(time.time()))))
849 if "idle_timeout" in user:
850 save_custom_attr(user_id, "idle_timeout", user["idle_timeout"])
851 else:
852 remove_custom_attr(user_id, "idle_timeout")
854 # Write out the last seent time
855 if 'last_seen' in user:
856 save_custom_attr(user_id, 'last_seen', repr(user['last_seen']))
858 save_cached_profile(user_id, user, multisite_keys, non_contact_keys)
861 # During deletion of users we don't delete files which might contain user settings
862 # and e.g. customized views which are not easy to reproduce. We want to keep the
863 # files which are the result of a lot of work even when e.g. the LDAP sync deletes
864 # a user by accident. But for some internal files it is ok to delete them.
866 # Be aware: The user_exists() function relies on these files to be deleted.
867 def _cleanup_old_user_profiles(updated_profiles):
868 profile_files_to_delete = [
869 "automation.secret",
870 "transids.mk",
871 "serial.mk",
873 directory = cmk.utils.paths.var_dir + "/web"
874 for user_dir in os.listdir(cmk.utils.paths.var_dir + "/web"):
875 if user_dir not in ['.', '..'] and user_dir.decode("utf-8") not in updated_profiles:
876 entry = directory + "/" + user_dir
877 if not os.path.isdir(entry):
878 continue
880 for to_delete in profile_files_to_delete:
881 if os.path.exists(entry + '/' + to_delete):
882 os.unlink(entry + '/' + to_delete)
885 def write_contacts_and_users_file(profiles, custom_default_config_dir=None):
886 non_contact_keys = _non_contact_keys()
887 multisite_keys = _multisite_keys()
888 updated_profiles = _add_custom_macro_attributes(profiles)
890 if custom_default_config_dir:
891 check_mk_config_dir = "%s/conf.d/wato" % custom_default_config_dir
892 multisite_config_dir = "%s/multisite.d/wato" % custom_default_config_dir
893 else:
894 check_mk_config_dir = "%s/conf.d/wato" % cmk.utils.paths.default_config_dir
895 multisite_config_dir = "%s/multisite.d/wato" % cmk.utils.paths.default_config_dir
897 non_contact_attributes_cache = {}
898 multisite_attributes_cache = {}
899 for user_settings in updated_profiles.itervalues():
900 connector = user_settings.get("connector")
901 if connector not in non_contact_attributes_cache:
902 non_contact_attributes_cache[connector] = non_contact_attributes(
903 user_settings.get('connector'))
904 if connector not in multisite_attributes_cache:
905 multisite_attributes_cache[connector] = multisite_attributes(
906 user_settings.get('connector'))
908 # Remove multisite keys in contacts.
909 # TODO: Clean this up. Just improved the performance, but still have no idea what its actually doing...
910 contacts = dict(
911 e for e in [(id,
912 split_dict(
913 user,
914 non_contact_keys + non_contact_attributes_cache[user.get('connector')],
915 False,
916 )) for (id, user) in updated_profiles.items()])
918 # Only allow explicitely defined attributes to be written to multisite config
919 users = {}
920 for uid, profile in updated_profiles.items():
921 users[uid] = dict(
922 [(p, val)
923 for p, val in profile.items()
924 if p in multisite_keys + multisite_attributes_cache[profile.get('connector')]])
926 # Check_MK's monitoring contacts
927 store.save_to_mk_file(
928 "%s/%s" % (check_mk_config_dir, "contacts.mk"),
929 "contacts",
930 contacts,
931 pprint_value=config.wato_pprint_config)
933 # GUI specific user configuration
934 store.save_to_mk_file(
935 "%s/%s" % (multisite_config_dir, "users.mk"),
936 "multisite_users",
937 users,
938 pprint_value=config.wato_pprint_config)
941 # User attributes not to put into contact definitions for Check_MK
942 def _non_contact_keys():
943 return [
944 "roles",
945 "password",
946 "locked",
947 "automation_secret",
948 "language",
949 "serial",
950 "connector",
951 "num_failed_logins",
952 "enforce_pw_change",
953 "last_pw_change",
954 "last_seen",
955 "idle_timeout",
956 ] + _get_multisite_custom_variable_names()
959 # User attributes to put into multisite configuration
960 def _multisite_keys():
961 return [
962 "roles",
963 "locked",
964 "automation_secret",
965 "alias",
966 "language",
967 "connector",
968 ] + _get_multisite_custom_variable_names()
971 def _get_multisite_custom_variable_names():
972 return [k for k, v in user_attributes.items() if v["domain"] == "multisite"]
975 def _save_auth_serials(updated_profiles):
976 # Write out the users serials
977 serials = ""
978 for user_id, user in updated_profiles.items():
979 serials += '%s:%d\n' % (cmk.utils.make_utf8(user_id), user.get('serial', 0))
980 store.save_file('%s/auth.serials' % os.path.dirname(cmk.utils.paths.htpasswd_file), serials)
983 def rewrite_users():
984 users = load_users(lock=True)
985 save_users(users)
988 def create_cmk_automation_user():
989 secret = utils.gen_id()
991 users = load_users(lock=True)
992 users["automation"] = {
993 'alias': u"Check_MK Automation - used for calling web services",
994 'contactgroups': [],
995 'automation_secret': secret,
996 'password': cmk.gui.plugins.userdb.htpasswd.hash_password(secret),
997 'roles': ['admin'],
998 'locked': False,
999 'serial': 0,
1000 'email': '',
1001 'pager': '',
1002 'notifications_enabled': False,
1003 'language': 'en',
1005 save_users(users)
1008 def save_cached_profile(user_id, user, multisite_keys, non_contact_keys):
1009 # Only save contact AND multisite attributes to the profile. Not the
1010 # infos that are stored in the custom attribute files.
1011 cache = {}
1012 for key in user.keys():
1013 if key in multisite_keys or key not in non_contact_keys:
1014 cache[key] = user[key]
1016 config.save_user_file("cached_profile", cache, user_id=user_id)
1019 def load_cached_profile():
1020 return config.user.load_file("cached_profile", None)
1023 def contactgroups_of_user(user_id):
1024 user = load_cached_profile()
1025 if user is None:
1026 # No cached profile present. Load all users to get the users data
1027 user = load_users(lock=False).get(user_id, {})
1029 return user.get("contactgroups", [])
1032 def convert_idle_timeout(value):
1033 if value == "False":
1034 return False # Idle timeout disabled
1036 try:
1037 return int(value)
1038 except ValueError:
1039 return None # Invalid value -> use global setting
1043 # .-Roles----------------------------------------------------------------.
1044 # | ____ _ |
1045 # | | _ \ ___ | | ___ ___ |
1046 # | | |_) / _ \| |/ _ \/ __| |
1047 # | | _ < (_) | | __/\__ \ |
1048 # | |_| \_\___/|_|\___||___/ |
1049 # | |
1050 # +----------------------------------------------------------------------+
1053 def load_roles():
1054 roles = store.load_from_mk_file(
1055 multisite_dir + "roles.mk",
1056 "roles",
1057 default=_get_builtin_roles(),
1060 # Make sure that "general." is prefixed to the general permissions
1061 # (due to a code change that converted "use" into "general.use", etc.
1062 # TODO: Can't we drop this? This seems to be from very early days of the GUI
1063 for role in roles.values():
1064 for pname, pvalue in role["permissions"].items():
1065 if "." not in pname:
1066 del role["permissions"][pname]
1067 role["permissions"]["general." + pname] = pvalue
1069 # Reflect the data in the roles dict kept in the config module needed
1070 # for instant changes in current page while saving modified roles.
1071 # Otherwise the hooks would work with old data when using helper
1072 # functions from the config module
1073 # TODO: load_roles() should not update global structures
1074 config.roles.update(roles)
1076 return roles
1079 def _get_builtin_roles():
1080 """Returns a role dictionary containing the bultin default roles"""
1081 builtin_role_names = {
1082 "admin": _("Administrator"),
1083 "user": _("Normal monitoring user"),
1084 "guest": _("Guest user"),
1086 return {
1087 rid: {
1088 "alias": builtin_role_names.get(rid, rid),
1089 "permissions": {}, # use default everywhere
1090 "builtin": True,
1091 } for rid in config.builtin_role_ids
1096 # .-Groups---------------------------------------------------------------.
1097 # | ____ |
1098 # | / ___|_ __ ___ _ _ _ __ ___ |
1099 # | | | _| '__/ _ \| | | | '_ \/ __| |
1100 # | | |_| | | | (_) | |_| | |_) \__ \ |
1101 # | \____|_| \___/ \__,_| .__/|___/ |
1102 # | |_| |
1103 # +----------------------------------------------------------------------+
1104 # TODO: Contact groups are fine here, but service / host groups?
1107 def load_group_information():
1108 cmk_base_groups = _load_cmk_base_groups()
1109 gui_groups = _load_gui_groups()
1111 # Merge information from Check_MK and Multisite worlds together
1112 groups = {}
1113 for what in ["host", "service", "contact"]:
1114 groups[what] = {}
1115 for gid, alias in cmk_base_groups['define_%sgroups' % what].items():
1116 groups[what][gid] = {'alias': alias}
1118 if gid in gui_groups['multisite_%sgroups' % what]:
1119 groups[what][gid].update(gui_groups['multisite_%sgroups' % what][gid])
1121 return groups
1124 def _load_cmk_base_groups():
1125 """Load group information from Check_MK world"""
1126 group_specs = {
1127 "define_hostgroups": {},
1128 "define_servicegroups": {},
1129 "define_contactgroups": {},
1132 return store.load_mk_file(root_dir + "groups.mk", default=group_specs)
1135 def _load_gui_groups():
1136 # Now load information from the Web world
1137 group_specs = {
1138 "multisite_hostgroups": {},
1139 "multisite_servicegroups": {},
1140 "multisite_contactgroups": {},
1143 return store.load_mk_file(multisite_dir + "groups.mk", default=group_specs)
1146 class GroupChoice(DualListChoice):
1147 def __init__(self, what, with_foreign_groups=True, **kwargs):
1148 DualListChoice.__init__(self, **kwargs)
1149 self.what = what
1150 self._choices = lambda: self.load_groups(with_foreign_groups)
1152 def load_groups(self, with_foreign_groups):
1153 all_groups = load_group_information()
1154 this_group = all_groups.get(self.what, {})
1155 return sorted([(k, t['alias'] and t['alias'] or k)
1156 for (k, t) in this_group.items()
1157 if with_foreign_groups or k in config.user.contact_groups()],
1158 key=lambda x: x[1].lower())
1162 # .-Custom-Attrs.--------------------------------------------------------.
1163 # | ____ _ _ _ _ |
1164 # | / ___| _ ___| |_ ___ _ __ ___ / \ | |_| |_ _ __ ___ |
1165 # | | | | | | / __| __/ _ \| '_ ` _ \ _____ / _ \| __| __| '__/ __| |
1166 # | | |__| |_| \__ \ || (_) | | | | | |_____/ ___ \ |_| |_| | \__ \_ |
1167 # | \____\__,_|___/\__\___/|_| |_| |_| /_/ \_\__|\__|_| |___(_) |
1168 # | |
1169 # +----------------------------------------------------------------------+
1170 # | Mange custom attributes of users (in future hosts etc.) |
1171 # '----------------------------------------------------------------------'
1174 def update_config_based_user_attributes():
1175 _clear_config_based_user_attributes()
1177 for attr in config.wato_user_attrs:
1178 if attr["type"] == "TextAscii":
1179 vs = TextAscii(title=attr['title'], help=attr['help'])
1180 else:
1181 raise NotImplementedError()
1183 # TODO: This method uses LegacyUserAttribute(). Use another class for
1184 # this kind of attribute
1185 declare_user_attribute(
1186 attr['name'],
1188 user_editable=attr['user_editable'],
1189 show_in_table=attr.get('show_in_table', False),
1190 topic=attr.get('topic', 'personal'),
1191 add_custom_macro=attr.get('add_custom_macro', False),
1192 from_config=True,
1196 def _clear_config_based_user_attributes():
1197 for attr_class in user_attribute_registry.values():
1198 attr = attr_class()
1199 if attr.from_config():
1200 del user_attribute_registry[attr.name()]
1204 # .--ConnectorCfg--------------------------------------------------------.
1205 # | ____ _ ____ __ |
1206 # | / ___|___ _ __ _ __ ___ ___| |_ ___ _ __ / ___|/ _| __ _ |
1207 # | | | / _ \| '_ \| '_ \ / _ \/ __| __/ _ \| '__| | | |_ / _` | |
1208 # | | |__| (_) | | | | | | | __/ (__| || (_) | | | |___| _| (_| | |
1209 # | \____\___/|_| |_|_| |_|\___|\___|\__\___/|_| \____|_| \__, | |
1210 # | |___/ |
1211 # +----------------------------------------------------------------------+
1212 # | The user can enable and configure a list of user connectors which |
1213 # | are then used by the userdb to fetch user / group information from |
1214 # | external sources like LDAP servers. |
1215 # '----------------------------------------------------------------------'
1218 def load_connection_config(lock=False):
1219 filename = os.path.join(multisite_dir, "user_connections.mk")
1220 return store.load_from_mk_file(filename, "user_connections", default=[], lock=lock)
1223 def save_connection_config(connections, base_dir=None):
1224 if not base_dir:
1225 base_dir = multisite_dir
1226 store.mkdir(base_dir)
1227 store.save_to_mk_file(
1228 os.path.join(base_dir, "user_connections.mk"), "user_connections", connections)
1230 for connector_class in user_connector_registry.values():
1231 connector_class.config_changed()
1235 # .-Hooks----------------------------------------------------------------.
1236 # | _ _ _ |
1237 # | | | | | ___ ___ | | _____ |
1238 # | | |_| |/ _ \ / _ \| |/ / __| |
1239 # | | _ | (_) | (_) | <\__ \ |
1240 # | |_| |_|\___/ \___/|_|\_\___/ |
1241 # | |
1242 # +----------------------------------------------------------------------+
1245 # This hook is called to validate the login credentials provided by a user
1246 def hook_login(username, password):
1247 for connection_id, connection in active_connections():
1248 result = connection.check_credentials(username, password)
1249 # None -> User unknown, means continue with other connectors
1250 # '<user_id>' -> success
1251 # False -> failed
1252 if result not in [False, None]:
1253 username = result
1254 if not isinstance(username, six.string_types):
1255 raise MKInternalError(
1256 _("The username returned by the %s "
1257 "connector is not of type string (%r).") % (connection_id, username))
1258 # Check whether or not the user exists (and maybe create it)
1259 create_non_existing_user(connection_id, username)
1261 if not is_customer_user_allowed_to_login(username):
1262 # A CME not assigned with the current sites customer
1263 # is not allowed to login
1264 auth_logger.debug("User '%s' is not allowed to login: Invalid customer" % username)
1265 return False
1267 # Now, after successfull login (and optional user account
1268 # creation), check whether or not the user is locked.
1269 # In e.g. htpasswd connector this is checked by validating the
1270 # password against the hash in the htpasswd file prefixed with
1271 # a "!". But when using other conectors it might be neccessary
1272 # to validate the user "locked" attribute.
1273 if connection.is_locked(username):
1274 auth_logger.debug("User '%s' is not allowed to login: Account locked" % username)
1275 return False # The account is locked
1277 return result
1279 elif result is False:
1280 return result
1283 def show_exception(connection_id, title, e, debug=True):
1284 html.show_error("<b>" + connection_id + ' - ' + title + "</b>"
1285 "<pre>%s</pre>" % (debug and traceback.format_exc() or e))
1288 # Hook function can be registered here to be executed during saving of the
1289 # new user construct
1290 def hook_save(users):
1291 for connection_id, connection in active_connections():
1292 try:
1293 connection.save_users(users)
1294 except Exception as e:
1295 if config.debug:
1296 raise
1297 else:
1298 show_exception(connection_id, _("Error during saving"), e)
1301 # This function registers general stuff, which is independet of the single
1302 # connectors to each page load. It is exectued AFTER all other connections jobs.
1303 def general_userdb_job():
1304 # Working around the problem that the auth.php file needed for multisite based
1305 # authorization of external addons might not exist when setting up a new installation
1306 # We assume: Each user must visit this login page before using the multisite based
1307 # authorization. So we can easily create the file here if it is missing.
1308 # This is a good place to replace old api based files in the future.
1309 auth_php = cmk.utils.paths.var_dir + '/wato/auth/auth.php'
1310 if not os.path.exists(auth_php) or os.path.getsize(auth_php) == 0:
1311 cmk.gui.plugins.userdb.hook_auth.create_auth_file("page_hook", load_users())
1313 # Create initial auth.serials file, same issue as auth.php above
1314 serials_file = '%s/auth.serials' % os.path.dirname(cmk.utils.paths.htpasswd_file)
1315 if not os.path.exists(serials_file) or os.path.getsize(serials_file) == 0:
1316 save_users(load_users(lock=True))
1319 def execute_userdb_job():
1320 """This function is called by the GUI cron job once a minute.
1322 Errors are logged to var/log/web.log. """
1323 if not userdb_sync_job_enabled():
1324 return
1326 job = UserSyncBackgroundJob()
1327 if job.is_running():
1328 logger.debug("Another synchronization job is already running: Skipping this sync")
1329 return
1331 job.set_function(job.do_sync, add_to_changelog=False, enforce_sync=False)
1332 job.start()
1335 # Legacy option config.userdb_automatic_sync defaulted to "master".
1336 # Can be: None: (no sync), "all": all sites sync, "master": only master site sync
1337 # Take that option into account for compatibility reasons.
1338 # For remote sites in distributed setups, the default is to do no sync.
1339 def user_sync_default_config(site_name):
1340 global_user_sync = transform_userdb_automatic_sync(config.userdb_automatic_sync)
1341 if global_user_sync == "master":
1342 if config.site_is_local(site_name) and not config.is_wato_slave_site():
1343 user_sync_default = "all"
1344 else:
1345 user_sync_default = None
1346 else:
1347 user_sync_default = global_user_sync
1349 return user_sync_default
1352 def user_sync_config():
1353 # use global option as default for reading legacy options and on remote site
1354 # for reading the value set by the WATO master site
1355 default_cfg = user_sync_default_config(config.omd_site())
1356 return config.site(config.omd_site()).get("user_sync", default_cfg)
1359 def userdb_sync_job_enabled():
1360 cfg = user_sync_config()
1362 if cfg is None:
1363 return False # not enabled at all
1365 if cfg == "master" and config.is_wato_slave_site():
1366 return False
1368 return True
1371 @cmk.gui.pages.register("ajax_userdb_sync")
1372 def ajax_sync():
1373 try:
1374 job = UserSyncBackgroundJob()
1375 job.set_function(job.do_sync, add_to_changelog=False, enforce_sync=True)
1376 try:
1377 job.start()
1378 except background_job.BackgroundJobAlreadyRunning as e:
1379 raise MKUserError(None, _("Another user synchronization is already running: %s") % e)
1380 html.write('OK Started synchronization\n')
1381 except Exception as e:
1382 logger.exception()
1383 if config.debug:
1384 raise
1385 else:
1386 html.write('ERROR %s\n' % e)
1389 @gui_background_job.job_registry.register
1390 class UserSyncBackgroundJob(gui_background_job.GUIBackgroundJob):
1391 job_prefix = "user_sync"
1392 gui_title = _("User synchronization")
1394 def __init__(self):
1395 kwargs = {}
1396 kwargs["title"] = self.gui_title
1397 kwargs["deletable"] = False
1398 kwargs["stoppable"] = False
1400 super(UserSyncBackgroundJob, self).__init__(self.job_prefix, **kwargs)
1402 def _back_url(self):
1403 return html.makeuri_contextless([("mode", "users")], filename="wato.py")
1405 def do_sync(self, job_interface, add_to_changelog, enforce_sync):
1406 job_interface.send_progress_update(_("Synchronization started..."))
1407 if self._execute_sync_action(job_interface, add_to_changelog, enforce_sync):
1408 job_interface.send_result_message(_("The user synchronization completed successfully."))
1409 else:
1410 job_interface.send_exception(_("The user synchronization failed."))
1412 def _execute_sync_action(self, job_interface, add_to_changelog, enforce_sync):
1413 for connection_id, connection in active_connections():
1414 try:
1415 if not enforce_sync and not connection.sync_is_needed():
1416 continue
1418 job_interface.send_progress_update(
1419 _("[%s] Starting sync for connection") % connection_id)
1420 connection.do_sync(add_to_changelog=add_to_changelog, only_username=False)
1421 job_interface.send_progress_update(
1422 _("[%s] Finished sync for connection") % connection_id)
1423 except Exception as e:
1424 job_interface.send_exception(_("[%s] Exception: %s") % (connection_id, e))
1425 logger.error(
1426 'Exception (%s, userdb_job): %s' % (connection_id, traceback.format_exc()))
1428 job_interface.send_progress_update(_("Finalizing synchronization"))
1429 general_userdb_job()
1430 return True