Andrew Bogott has submitted this change and it was merged. ( 
https://gerrit.wikimedia.org/r/346489 )

Change subject: Keystonehooks:  Create and delete sudoer rules in ldap
......................................................................


Keystonehooks:  Create and delete sudoer rules in ldap

On creation, we add some default, permissive rules.

On deletion, we delete all sudo rules and the 'project' record
which isn't used for anything but this.

Bug: T150091
Change-Id: I89f5be2eeec348063de0a3f9662e30897f6590d7
---
M modules/openstack/files/liberty/keystone/wmfkeystonehooks/ldapgroups.py
M modules/openstack/files/liberty/keystone/wmfkeystonehooks/wmfkeystonehooks.py
A modules/openstack/files/mitaka/keystone/wmfkeystonehooks/ldapgroups.py
M modules/openstack/files/mitaka/keystone/wmfkeystonehooks/wmfkeystonehooks.py
4 files changed, 392 insertions(+), 10 deletions(-)

Approvals:
  Andrew Bogott: Looks good to me, approved
  jenkins-bot: Verified



diff --git 
a/modules/openstack/files/liberty/keystone/wmfkeystonehooks/ldapgroups.py 
b/modules/openstack/files/liberty/keystone/wmfkeystonehooks/ldapgroups.py
index 8f4bced..e77067d 100644
--- a/modules/openstack/files/liberty/keystone/wmfkeystonehooks/ldapgroups.py
+++ b/modules/openstack/files/liberty/keystone/wmfkeystonehooks/ldapgroups.py
@@ -113,7 +113,23 @@
         LOG.error("Failed to connect to ldap; Leak a project group.")
         raise exception.ValidationError()
 
-    ds.delete_s(dn)
+    try:
+        ds.delete_s(dn)
+    except ldap.LDAPError as e:
+        LOG.warning("Failed to delete %s from ldap: %s" % (dn, e))
+
+    # delete everything under the project subtree
+    basedn = cfg.CONF.wmfhooks.ldap_project_base_dn
+    projectbase = "cn=%s,%s" % (project_id, basedn)
+
+    search = ds.search_s(projectbase, ldap.SCOPE_SUBTREE)
+    delete_list = [record for record, _ in search]
+    delete_list.reverse()
+    for record in delete_list:
+        try:
+            ds.delete_s(record)
+        except ldap.LDAPError as e:
+            LOG.warning("Failed to delete %s from ldap" % (record, e))
 
 
 def sync_ldap_project_group(project_id, keystone_assignments):
@@ -167,3 +183,56 @@
             except ldap.LDAPError:
                 LOG.warning("Failed to create group, attempt number %s: %s" %
                             (i, modlist))
+
+
+def create_sudo_defaults(project_id):
+    ds = _open_ldap()
+    if not ds:
+        LOG.error("Failed to connect to ldap; Unable to create sudo rules.")
+        raise exception.ValidationError()
+
+    userbasedn = cfg.CONF.wmfhooks.ldap_user_base_dn
+    basedn = cfg.CONF.wmfhooks.ldap_project_base_dn
+    projectbase = "cn=%s,%s" % (project_id, basedn)
+    # We may or may not already have one of these... if it fails just move on.
+    projectEntry = {}
+    projectEntry['objectClass'] = ['extensibleobject', 'groupofnames', 'top']
+    projectEntry['member'] = ["uid=%s,%s" % (cfg.CONF.wmfhooks.admin_user, 
userbasedn)]
+    modlist = ldap.modlist.addModlist(projectEntry)
+    try:
+        ds.add_s(projectbase, modlist)
+    except ldap.LDAPError as e:
+        LOG.warning("Failed to create project base %s in ldap: %s" % 
(projectbase, e))
+
+    sudoerbase = "ou=sudoers,%s" % projectbase
+    sudoEntry = {}
+    sudoEntry['objectClass'] = ['organizationalunit', 'top']
+    modlist = ldap.modlist.addModlist(sudoEntry)
+    try:
+        ds.add_s(sudoerbase, modlist)
+    except ldap.LDAPError as e:
+        LOG.warning("Failed to create base sudoer group: %s" % e)
+
+    sudoEntry = {}
+    defaultdn = "cn=default-sudo,%s" % sudoerbase
+    sudoEntry['objectClass'] = ['sudoRole']
+    sudoEntry['sudoUser'] = ['%%project-%s' % project_id.encode('utf8')]
+    sudoEntry['sudoCommand'] = ['ALL']
+    sudoEntry['sudoOption'] = ['!authenticate']
+    sudoEntry['sudoHost'] = ['ALL']
+    sudoEntry['cn'] = ['default-sudo']
+    modlist = ldap.modlist.addModlist(sudoEntry)
+    try:
+        ds.add_s(defaultdn, modlist)
+    except ldap.LDAPError as e:
+        LOG.warning("Failed to create default sudoer entry: %s" % e)
+
+    defaultasdn = "cn=default-sudo-as,%s" % sudoerbase
+    # The runas entry is the same as the default entry, plus one field
+    sudoEntry['sudoRunAsUser'] = ["%%project-%s" % project_id.encode('utf8')]
+    sudoEntry['cn'] = ['default-sudo-as']
+    modlist = ldap.modlist.addModlist(sudoEntry)
+    try:
+        ds.add_s(defaultasdn, modlist)
+    except ldap.LDAPError as e:
+        LOG.warning("Failed to create default sudo-as entry: %s" % e)
diff --git 
a/modules/openstack/files/liberty/keystone/wmfkeystonehooks/wmfkeystonehooks.py 
b/modules/openstack/files/liberty/keystone/wmfkeystonehooks/wmfkeystonehooks.py
index 85c7f22..284b8a5 100644
--- 
a/modules/openstack/files/liberty/keystone/wmfkeystonehooks/wmfkeystonehooks.py
+++ 
b/modules/openstack/files/liberty/keystone/wmfkeystonehooks/wmfkeystonehooks.py
@@ -65,6 +65,9 @@
     cfg.StrOpt('ldap_user_base_dn',
                default='ou=people,dc=wikimedia,dc=org',
                help='ldap dn for user accounts'),
+    cfg.StrOpt('ldap_project_base_dn',
+               default='ou=projects,dc=wikimedia,dc=org',
+               help='ldap dn for project records'),
     cfg.IntOpt('minimum_gid_number',
                default=40000,
                help='Starting gid number for posix groups'),
@@ -221,6 +224,9 @@
         assignments = self._get_current_assignments(project_id)
         ldapgroups.sync_ldap_project_group(project_id, assignments)
 
+        # Set up default sudoers in ldap
+        ldapgroups.create_sudo_defaults(project_id)
+
     def notify(self, context, message, priority, retry=False):
         event_type = message.get('event_type')
 
diff --git 
a/modules/openstack/files/mitaka/keystone/wmfkeystonehooks/ldapgroups.py 
b/modules/openstack/files/mitaka/keystone/wmfkeystonehooks/ldapgroups.py
new file mode 100644
index 0000000..e77067d
--- /dev/null
+++ b/modules/openstack/files/mitaka/keystone/wmfkeystonehooks/ldapgroups.py
@@ -0,0 +1,238 @@
+# Copyright 2016 Andrew Bogott for the Wikimedia Foundation
+# All Rights Reserved.
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+import ldap
+import ldap.modlist
+
+from keystone import exception
+
+from oslo_log import log as logging
+from oslo_config import cfg
+
+LOG = logging.getLogger('nova.%s' % __name__)
+
+
+def _getLdapInfo(attr, conffile="/etc/ldap.conf"):
+    try:
+        f = open(conffile)
+    except IOError:
+        if conffile == "/etc/ldap.conf":
+            # fallback to /etc/ldap/ldap.conf, which will likely
+            # have less information
+            f = open("/etc/ldap/ldap.conf")
+    for line in f:
+        if line.strip() == "":
+            continue
+        if line.split()[0].lower() == attr.lower():
+            return line.split(None, 1)[1].strip()
+            break
+
+
+def _open_ldap():
+    ldapHost = _getLdapInfo("uri")
+    sslType = _getLdapInfo("ssl")
+
+    binddn = cfg.CONF.ldap.user
+    bindpw = cfg.CONF.ldap.password
+    ds = ldap.initialize(ldapHost)
+    ds.protocol_version = ldap.VERSION3
+    if sslType == "start_tls":
+        ds.start_tls_s()
+
+    try:
+        ds.simple_bind_s(binddn, bindpw)
+        return ds
+    except ldap.CONSTRAINT_VIOLATION:
+        LOG.debug("LDAP bind failure:  Too many failed attempts.\n")
+    except ldap.INVALID_DN_SYNTAX:
+        LOG.debug("LDAP bind failure:  The bind DN is incorrect... \n")
+    except ldap.NO_SUCH_OBJECT:
+        LOG.debug("LDAP bind failure:  "
+                  "Unable to locate the bind DN account.\n")
+    except ldap.UNWILLING_TO_PERFORM as msg:
+        LOG.debug("LDAP bind failure:  "
+                  "The LDAP server was unwilling to perform the action"
+                  " requested.\nError was: %s\n" % msg[0]["info"])
+    except ldap.INVALID_CREDENTIALS:
+        LOG.debug("LDAP bind failure:  Password incorrect.\n")
+
+    return None
+
+
+# ds is presumed to be an already-open ldap connection
+def _all_groups(ds):
+    basedn = cfg.CONF.wmfhooks.ldap_group_base_dn
+    allgroups = ds.search_s(basedn, ldap.SCOPE_ONELEVEL)
+    return allgroups
+
+
+# ds is presumed to be an already-open ldap connection
+def _get_next_gid_number(ds):
+    highest = cfg.CONF.wmfhooks.minimum_gid_number
+    for group in _all_groups(ds):
+        if 'gidNumber' in group[1]:
+            number = int(group[1]['gidNumber'][0])
+            if number > highest:
+                highest = number
+
+    # Fixme:  Check against a hard max gid number limit?
+    return highest + 1
+
+
+# ds should be an already-open ldap connection.
+#
+# groupname is the name of the group to create, probably project-<projectname>
+def _get_ldap_group(ds, groupname):
+    basedn = cfg.CONF.wmfhooks.ldap_group_base_dn
+    searchdn = "cn=%s,%s" % (groupname, basedn)
+    try:
+        thisgroup = ds.search_s(searchdn, ldap.SCOPE_BASE)
+        return thisgroup
+    except ldap.LDAPError:
+        return None
+
+
+def delete_ldap_project_group(project_id):
+    basedn = cfg.CONF.wmfhooks.ldap_group_base_dn
+    groupname = "project-%s" % project_id.encode('utf-8')
+    dn = "cn=%s,%s" % (groupname, basedn)
+
+    ds = _open_ldap()
+    if not ds:
+        LOG.error("Failed to connect to ldap; Leak a project group.")
+        raise exception.ValidationError()
+
+    try:
+        ds.delete_s(dn)
+    except ldap.LDAPError as e:
+        LOG.warning("Failed to delete %s from ldap: %s" % (dn, e))
+
+    # delete everything under the project subtree
+    basedn = cfg.CONF.wmfhooks.ldap_project_base_dn
+    projectbase = "cn=%s,%s" % (project_id, basedn)
+
+    search = ds.search_s(projectbase, ldap.SCOPE_SUBTREE)
+    delete_list = [record for record, _ in search]
+    delete_list.reverse()
+    for record in delete_list:
+        try:
+            ds.delete_s(record)
+        except ldap.LDAPError as e:
+            LOG.warning("Failed to delete %s from ldap" % (record, e))
+
+
+def sync_ldap_project_group(project_id, keystone_assignments):
+    groupname = "project-%s" % project_id.encode('utf-8')
+    LOG.info("Syncing keystone project membership with ldap group %s"
+             % groupname)
+    ds = _open_ldap()
+    if not ds:
+        LOG.error("Failed to connect to ldap; cannot set up new project.")
+        raise exception.ValidationError()
+
+    allusers = set()
+    for key in keystone_assignments:
+        allusers |= set(keystone_assignments[key])
+
+    if 'novaobserver' in allusers:
+        allusers.remove('novaobserver')
+
+    basedn = cfg.CONF.wmfhooks.ldap_user_base_dn
+    members = ["uid=%s,%s" % (user.encode('utf-8'), basedn)
+               for user in allusers]
+
+    basedn = cfg.CONF.wmfhooks.ldap_group_base_dn
+    dn = "cn=%s,%s" % (groupname, basedn)
+
+    existingEntry = _get_ldap_group(ds, groupname)
+    if existingEntry:
+        # We're modifying an existing group
+        oldEntry = existingEntry[0][1]
+        newEntry = oldEntry.copy()
+        newEntry['member'] = members
+
+        modlist = ldap.modlist.modifyModlist(oldEntry, newEntry)
+        if modlist:
+            ds.modify_s(dn, modlist)
+    else:
+        # We're creating a new group from scratch.
+        #  There is a potential race between _get_next_git_number()
+        #  and ds.add_s, so we make a few attempts.
+        #  around this function.
+        groupEntry = {}
+        groupEntry['member'] = members
+        groupEntry['objectClass'] = ['groupOfNames', 'posixGroup', 'top']
+        groupEntry['cn'] = [groupname]
+        for i in range(0, 4):
+            groupEntry['gidNumber'] = [str(_get_next_gid_number(ds))]
+            modlist = ldap.modlist.addModlist(groupEntry)
+            try:
+                ds.add_s(dn, modlist)
+                break
+            except ldap.LDAPError:
+                LOG.warning("Failed to create group, attempt number %s: %s" %
+                            (i, modlist))
+
+
+def create_sudo_defaults(project_id):
+    ds = _open_ldap()
+    if not ds:
+        LOG.error("Failed to connect to ldap; Unable to create sudo rules.")
+        raise exception.ValidationError()
+
+    userbasedn = cfg.CONF.wmfhooks.ldap_user_base_dn
+    basedn = cfg.CONF.wmfhooks.ldap_project_base_dn
+    projectbase = "cn=%s,%s" % (project_id, basedn)
+    # We may or may not already have one of these... if it fails just move on.
+    projectEntry = {}
+    projectEntry['objectClass'] = ['extensibleobject', 'groupofnames', 'top']
+    projectEntry['member'] = ["uid=%s,%s" % (cfg.CONF.wmfhooks.admin_user, 
userbasedn)]
+    modlist = ldap.modlist.addModlist(projectEntry)
+    try:
+        ds.add_s(projectbase, modlist)
+    except ldap.LDAPError as e:
+        LOG.warning("Failed to create project base %s in ldap: %s" % 
(projectbase, e))
+
+    sudoerbase = "ou=sudoers,%s" % projectbase
+    sudoEntry = {}
+    sudoEntry['objectClass'] = ['organizationalunit', 'top']
+    modlist = ldap.modlist.addModlist(sudoEntry)
+    try:
+        ds.add_s(sudoerbase, modlist)
+    except ldap.LDAPError as e:
+        LOG.warning("Failed to create base sudoer group: %s" % e)
+
+    sudoEntry = {}
+    defaultdn = "cn=default-sudo,%s" % sudoerbase
+    sudoEntry['objectClass'] = ['sudoRole']
+    sudoEntry['sudoUser'] = ['%%project-%s' % project_id.encode('utf8')]
+    sudoEntry['sudoCommand'] = ['ALL']
+    sudoEntry['sudoOption'] = ['!authenticate']
+    sudoEntry['sudoHost'] = ['ALL']
+    sudoEntry['cn'] = ['default-sudo']
+    modlist = ldap.modlist.addModlist(sudoEntry)
+    try:
+        ds.add_s(defaultdn, modlist)
+    except ldap.LDAPError as e:
+        LOG.warning("Failed to create default sudoer entry: %s" % e)
+
+    defaultasdn = "cn=default-sudo-as,%s" % sudoerbase
+    # The runas entry is the same as the default entry, plus one field
+    sudoEntry['sudoRunAsUser'] = ["%%project-%s" % project_id.encode('utf8')]
+    sudoEntry['cn'] = ['default-sudo-as']
+    modlist = ldap.modlist.addModlist(sudoEntry)
+    try:
+        ds.add_s(defaultasdn, modlist)
+    except ldap.LDAPError as e:
+        LOG.warning("Failed to create default sudo-as entry: %s" % e)
diff --git 
a/modules/openstack/files/mitaka/keystone/wmfkeystonehooks/wmfkeystonehooks.py 
b/modules/openstack/files/mitaka/keystone/wmfkeystonehooks/wmfkeystonehooks.py
index 78c2796..284b8a5 100644
--- 
a/modules/openstack/files/mitaka/keystone/wmfkeystonehooks/wmfkeystonehooks.py
+++ 
b/modules/openstack/files/mitaka/keystone/wmfkeystonehooks/wmfkeystonehooks.py
@@ -12,6 +12,7 @@
 #    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 #    License for the specific language governing permissions and limitations
 #    under the License.
+import ldapgroups
 
 from keystoneclient.auth.identity import generic
 from keystoneclient import session as keystone_session
@@ -58,6 +59,18 @@
     cfg.StrOpt('admin_role_name',
                default='projectadmin',
                help='Name of project-local admin role'),
+    cfg.StrOpt('ldap_group_base_dn',
+               default='ou=groups,dc=wikimedia,dc=org',
+               help='ldap dn for posix groups'),
+    cfg.StrOpt('ldap_user_base_dn',
+               default='ou=people,dc=wikimedia,dc=org',
+               help='ldap dn for user accounts'),
+    cfg.StrOpt('ldap_project_base_dn',
+               default='ou=projects,dc=wikimedia,dc=org',
+               help='ldap dn for project records'),
+    cfg.IntOpt('minimum_gid_number',
+               default=40000,
+               help='Starting gid number for posix groups'),
     cfg.MultiStrOpt('eventtype_whitelist',
                     default=['identity.project.deleted', 
'identity.project.created'],
                     help='Event types to always handle.'),
@@ -80,18 +93,44 @@
     def __init__(self, conf, topics, transport, version=1.0):
         pass
 
-    def _on_project_delete(self, project_id):
-        LOG.warning("Beginning wmf hooks for project deletion: %s" % 
project_id)
-
-    def _on_project_create(self, project_id):
-
-        LOG.warning("Beginning wmf hooks for project creation: %s" % 
project_id)
-
+    def _get_role_dict(self):
         rolelist = self.role_api.list_roles()
         roledict = {}
         # Make a dict to relate role names to ids
         for role in rolelist:
             roledict[role['name']] = role['id']
+
+        return roledict
+
+    def _get_current_assignments(self, project_id):
+        reverseroledict = dict((v, k) for k, v in 
self._get_role_dict().iteritems())
+
+        rawassignments = 
self.assignment_api.list_role_assignments(project_id=project_id)
+        assignments = {}
+        for assignment in rawassignments:
+            rolename = reverseroledict[assignment["role_id"]]
+            if rolename not in assignments:
+                assignments[rolename] = set()
+            assignments[rolename].add(assignment["user_id"])
+        return assignments
+
+    # There are a bunch of different events which might update project 
membership,
+    #  and the generic 'identity.projectupdated' comes in the wrong order.  So
+    #  we're probably going to wind up getting called several times in quick 
succession,
+    #  possible in overlapping invocations.  Watch out for race conditions!
+    def _on_member_update(self, project_id):
+        assignments = self._get_current_assignments(project_id)
+        ldapgroups.sync_ldap_project_group(project_id, assignments)
+
+    def _on_project_delete(self, project_id):
+        ldapgroups.delete_ldap_project_group(project_id)
+
+    def _on_project_create(self, project_id):
+
+        LOG.warning("Beginning wmf hooks for project creation: %s" % 
project_id)
+
+        roledict = self._get_role_dict()
+
         if CONF.wmfhooks.observer_role_name not in roledict.keys():
             LOG.error("Failed to find id for role %s" % 
CONF.wmfhooks.observer_role_name)
             raise exception.NotImplemented()
@@ -121,7 +160,7 @@
             project_domain_name='Default',
             project_name=project_id)
         session = keystone_session.Session(auth=auth)
-        client = nova_client.Client('2', session=session)
+        client = nova_client.Client('2', session=session, connect_retries=5)
         allgroups = client.security_groups.list()
         defaultgroup = filter(lambda group: group.name == 'default', allgroups)
         if defaultgroup:
@@ -158,9 +197,35 @@
                                                    cidr='',
                                                    group_id=groupid)
             except (exceptions.ClientException):
-                LOG.warning("Project security rule already exists.")
+                LOG.warning("Project security rule for TCP already exists.")
+
+            try:
+                client.security_group_rules.create(groupid,
+                                                   ip_protocol='udp',
+                                                   from_port='1',
+                                                   to_port='65535',
+                                                   cidr='',
+                                                   group_id=groupid)
+            except (exceptions.ClientException):
+                LOG.warning("Project security rule for UDP already exists.")
+
+            try:
+                client.security_group_rules.create(groupid,
+                                                   ip_protocol='icmp',
+                                                   from_port='1',
+                                                   to_port='65535',
+                                                   cidr='',
+                                                   group_id=groupid)
+            except (exceptions.ClientException):
+                LOG.warning("Project security rule for ICMP already exists.")
         else:
             LOG.warning("Failed to find default security group in new 
project.")
+
+        assignments = self._get_current_assignments(project_id)
+        ldapgroups.sync_ldap_project_group(project_id, assignments)
+
+        # Set up default sudoers in ldap
+        ldapgroups.create_sudo_defaults(project_id)
 
     def notify(self, context, message, priority, retry=False):
         event_type = message.get('event_type')
@@ -171,6 +236,10 @@
         if event_type == 'identity.project.created':
             self._on_project_create(message['payload']['resource_info'])
 
+        if (event_type == 'identity.role_assignment.deleted' or
+                event_type == 'identity.role_assignment.created'):
+            self._on_member_update(message['payload']['project'])
+
         # Eventually this will be used to update project resource pages:
         if event_type in CONF.wmfhooks.eventtype_blacklist:
             return

-- 
To view, visit https://gerrit.wikimedia.org/r/346489
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: merged
Gerrit-Change-Id: I89f5be2eeec348063de0a3f9662e30897f6590d7
Gerrit-PatchSet: 3
Gerrit-Project: operations/puppet
Gerrit-Branch: production
Gerrit-Owner: Andrew Bogott <[email protected]>
Gerrit-Reviewer: Alex Monk <[email protected]>
Gerrit-Reviewer: Andrew Bogott <[email protected]>
Gerrit-Reviewer: Giuseppe Lavagetto <[email protected]>
Gerrit-Reviewer: Volans <[email protected]>
Gerrit-Reviewer: jenkins-bot <>

_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to