gpo: Add GPO unapply
authorDavid Mulder <dmulder@suse.com>
Thu, 8 Jun 2017 17:47:57 +0000 (11:47 -0600)
committerGarming Sam <garming@samba.org>
Mon, 20 Nov 2017 20:41:15 +0000 (21:41 +0100)
Keep a log of applied settings, and add an option to samba_gpoupdate to allow unapply. An unapply will revert settings to a state prior to any policy application.

Signed-off-by: David Mulder <dmulder@suse.com>
Reviewed-by: Garming Sam <garming@catalyst.net.nz>
Reviewed-by: Andrew Bartlett <abartlet@samba.org>
python/samba/gpclass.py
source4/scripting/bin/samba_gpoupdate

index a945969dde067026e4101af1d41c8fdbf87ee93f..ae06c5b361d3207ccd18e44d24bfaf7ef31d3083 100644 (file)
@@ -31,30 +31,200 @@ from samba import NTSTATUSError
 from ConfigParser import ConfigParser
 from StringIO import StringIO
 from abc import ABCMeta, abstractmethod
 from ConfigParser import ConfigParser
 from StringIO import StringIO
 from abc import ABCMeta, abstractmethod
+import xml.etree.ElementTree as etree
+
+class gp_log:
+    ''' Log settings overwritten by gpo apply
+    The gp_log is an xml file that stores a history of gpo changes (and the original setting value).
+
+    The log is organized like so:
+
+<gp>
+    <user name="KDC-1$">
+        <applylog>
+            <guid count="0" value="{31B2F340-016D-11D2-945F-00C04FB984F9}" />
+        </applylog>
+        <guid value="{31B2F340-016D-11D2-945F-00C04FB984F9}">
+            <gp_ext name="System Access">
+                <attribute name="minPwdAge">-864000000000</attribute>
+                <attribute name="maxPwdAge">-36288000000000</attribute>
+                <attribute name="minPwdLength">7</attribute>
+                <attribute name="pwdProperties">1</attribute>
+            </gp_ext>
+            <gp_ext name="Kerberos Policy">
+                <attribute name="ticket_lifetime">1d</attribute>
+                <attribute name="renew_lifetime" />
+                <attribute name="clockskew">300</attribute>
+            </gp_ext>
+        </guid>
+    </user>
+</gp>
+
+    Each guid value contains a list of extensions, which contain a list of attributes. The guid value
+    represents a GPO. The attributes are the values of those settings prior to the application of
+    the GPO.
+    The list of guids is enclosed within a user name, which represents the user the settings were
+    applied to. This user may be the samaccountname of the local computer, which implies that these
+    are machine policies.
+    The applylog keeps track of the order in which the GPOs were applied, so that they can be rolled
+    back in reverse, returning the machine to the state prior to policy application.
+    '''
+    def __init__(self, user, gpostore, db_log=None):
+        ''' Initialize the gp_log
+        param user          - the username (or machine name) that policies are being applied to
+        param gpostore      - the GPOStorage obj which references the tdb which contains gp_logs
+        param db_log        - (optional) a string to initialize the gp_log
+        '''
+        self.gpostore = gpostore
+        self.username = user
+        if db_log:
+            self.gpdb = etree.fromstring(db_log)
+        else:
+            self.gpdb = etree.Element('gp')
+        self.user = self.gpdb.find('user[@name="%s"]' % user)
+        if self.user is None:
+            self.user = etree.SubElement(self.gpdb, 'user')
+            self.user.attrib['name'] = user
+
+    def set_guid(self, guid):
+        ''' Log to a different GPO guid
+        param guid          - guid value of the GPO from which we're applying policy
+        '''
+        self.guid = self.user.find('guid[@value="%s"]' % guid)
+        if self.guid is None:
+            self.guid = etree.SubElement(self.user, 'guid')
+            self.guid.attrib['value'] = guid
+        apply_log = self.user.find('applylog')
+        if apply_log is None:
+            apply_log = etree.SubElement(self.user, 'applylog')
+        item = etree.SubElement(apply_log, 'guid')
+        item.attrib['count'] = '%d' % (len(apply_log)-1)
+        item.attrib['value'] = guid
+
+    def apply_log_pop(self):
+        ''' Pop a GPO guid from the applylog
+        return              - last applied GPO guid
+
+        Removes the GPO guid last added to the list, which is the most recently applied GPO.
+        '''
+        apply_log = self.user.find('applylog')
+        if apply_log is not None:
+            ret = apply_log.find('guid[@count="%d"]' % (len(apply_log)-1))
+            if ret is not None:
+                apply_log.remove(ret)
+                return ret.attrib['value']
+            if len(apply_log) == 0 and apply_log in self.user:
+                self.user.remove(apply_log)
+        return None
+
+    def store(self, gp_ext_name, attribute, old_val):
+        ''' Store an attribute in the gp_log
+        param gp_ext_name   - Name of the extension applying policy
+        param attribute     - The attribute being modified
+        param old_val       - The value of the attribute prior to policy application
+        '''
+        assert self.guid is not None, "gpo guid was not set"
+        ext = self.guid.find('gp_ext[@name="%s"]' % gp_ext_name)
+        if ext is None:
+            ext = etree.SubElement(self.guid, 'gp_ext')
+            ext.attrib['name'] = gp_ext_name
+        attr = ext.find('attribute[@name="%s"]' % attribute)
+        if attr is None:
+            attr = etree.SubElement(ext, 'attribute')
+            attr.attrib['name'] = attribute
+        attr.text = old_val
+
+    def retrieve(self, gp_ext_name, attribute):
+        ''' Retrieve a stored attribute from the gp_log
+        param gp_ext_name   - Name of the extension which applied policy
+        param attribute     - The attribute being retrieved
+        return              - The value of the attribute prior to policy application
+        '''
+        assert self.guid is not None, "gpo guid was not set"
+        ext = self.guid.find('gp_ext[@name="%s"]' % gp_ext_name)
+        if ext is not None:
+            attr = ext.find('attribute[@name="%s"]' % attribute)
+            if attr is not None:
+                return attr.text
+        return None
+
+    def list(self, gp_extensions):
+        ''' Return a list of attributes, their previous values, and functions to set them
+        param gp_extensions - list of extension objects, for retrieving attr to func mappings
+        return              - list of (attr, value, apply_func) tuples for unapplying policy
+        '''
+        assert self.guid is not None, "gpo guid was not set"
+        ret = []
+        data_maps = {}
+        for gp_ext in gp_extensions:
+            data_maps.update(gp_ext.apply_map())
+        exts = self.guid.findall('gp_ext')
+        if exts is not None:
+            for ext in exts:
+                ext_map = {val[0]: val[1] for (key, val) in data_maps[ext.attrib['name']].items()}
+                attrs = ext.findall('attribute')
+                for attr in attrs:
+                    ret.append((attr.attrib['name'], attr.text, ext_map[attr.attrib['name']]))
+        return ret
 
 
-class Backlog:
-    def __init__(self, sysvol_log):
-        if os.path.isfile(sysvol_log):
-            self.backlog = tdb.open(sysvol_log)
+    def delete(self, gp_ext_name, attribute):
+        ''' Remove an attribute from the gp_log
+        param gp_ext_name   - name of extension from which to remove the attribute
+        param attribute     - attribute to remove
+        '''
+        assert self.guid is not None, "gpo guid was not set"
+        ext = self.guid.find('gp_ext[@name="%s"]' % gp_ext_name)
+        if ext is not None:
+            attr = ext.find('attribute[@name="%s"]' % attribute)
+            if attr is not None:
+                ext.remove(attr)
+                if len(ext) == 0:
+                    self.guid.remove(ext)
+
+    def commit(self):
+        ''' Write gp_log changes to disk '''
+        if len(self.guid) == 0 and self.guid in self.user:
+            self.user.remove(self.guid)
+        if len(self.user) == 0 and self.user in self.gpdb:
+            self.gpdb.remove(self.user)
+        self.gpostore.store(self.username, etree.tostring(self.gpdb, 'utf-8'))
+
+class GPOStorage:
+    def __init__(self, log_file):
+        if os.path.isfile(log_file):
+            self.log = tdb.open(log_file)
         else:
         else:
-            self.backlog = tdb.Tdb(sysvol_log, 0, tdb.DEFAULT, os.O_CREAT|os.O_RDWR)
-        self.backlog.transaction_start()
+            self.log = tdb.Tdb(log_file, 0, tdb.DEFAULT, os.O_CREAT|os.O_RDWR)
 
 
-    def version(self, guid):
+    def start(self):
+        self.log.transaction_start()
+
+    def get_int(self, key):
         try:
         try:
-            old_version = int(self.backlog.get(guid))
+            return int(self.log.get(key))
         except TypeError:
         except TypeError:
-            old_version = -1
-        return old_version
+            return None
+
+    def get(self, key):
+        return self.log.get(key)
+
+    def get_gplog(self, user):
+        return gp_log(user, self, self.log.get(user))
+
+    def store(self, key, val):
+        self.log.store(key, val)
+
+    def cancel(self):
+        self.log.transaction_cancel()
 
 
-    def store(self, guid, version):
-        self.backlog.store(guid, '%i' % version)
+    def delete(self, key):
+        self.log.delete(key)
 
     def commit(self):
 
     def commit(self):
-        self.backlog.transaction_commit()
+        self.log.transaction_commit()
 
     def __del__(self):
 
     def __del__(self):
-        self.backlog.close()
+        self.log.close()
 
 class gp_ext(object):
     __metaclass__ = ABCMeta
 
 class gp_ext(object):
     __metaclass__ = ABCMeta
@@ -64,23 +234,27 @@ class gp_ext(object):
         pass
 
     @abstractmethod
         pass
 
     @abstractmethod
-    def parse(self, afile, ldb, conn, lp):
+    def apply_map(self):
         pass
 
     @abstractmethod
         pass
 
     @abstractmethod
-    def __str__(self):
+    def parse(self, afile, ldb, conn, gp_db, lp):
         pass
 
         pass
 
+    @abstractmethod
+    def __str__(self):
+        pass
 
 class inf_to():
     __metaclass__ = ABCMeta
 
 
 class inf_to():
     __metaclass__ = ABCMeta
 
-    def __init__(self, logger, ldb, lp, attribute, val):
+    def __init__(self, logger, ldb, gp_db, lp, attribute, val):
         self.logger = logger
         self.ldb = ldb
         self.attribute = attribute
         self.val = val
         self.lp = lp
         self.logger = logger
         self.ldb = ldb
         self.attribute = attribute
         self.val = val
         self.lp = lp
+        self.gp_db = gp_db
 
     def explicit(self):
         return self.val
 
     def explicit(self):
         return self.val
@@ -93,6 +267,10 @@ class inf_to():
     def mapper(self):
         pass
 
     def mapper(self):
         pass
 
+    @abstractmethod
+    def __str__(self):
+        pass
+
 class inf_to_ldb(inf_to):
     '''This class takes the .inf file parameter (essentially a GPO file mapped to a GUID),
     hashmaps it to the Samba parameter, which then uses an ldb object to update the
 class inf_to_ldb(inf_to):
     '''This class takes the .inf file parameter (essentially a GPO file mapped to a GUID),
     hashmaps it to the Samba parameter, which then uses an ldb object to update the
@@ -100,19 +278,27 @@ class inf_to_ldb(inf_to):
     '''
 
     def ch_minPwdAge(self, val):
     '''
 
     def ch_minPwdAge(self, val):
-        self.logger.info('KDC Minimum Password age was changed from %s to %s' % (self.ldb.get_minPwdAge(), val))
+        old_val = self.ldb.get_minPwdAge()
+        self.logger.info('KDC Minimum Password age was changed from %s to %s' % (old_val, val))
+        self.gp_db.store(str(self), self.attribute, old_val)
         self.ldb.set_minPwdAge(val)
 
     def ch_maxPwdAge(self, val):
         self.ldb.set_minPwdAge(val)
 
     def ch_maxPwdAge(self, val):
-        self.logger.info('KDC Maximum Password age was changed from %s to %s' % (self.ldb.get_maxPwdAge(), val))
+        old_val = self.ldb.get_maxPwdAge()
+        self.logger.info('KDC Maximum Password age was changed from %s to %s' % (old_val, val))
+        self.gp_db.store(str(self), self.attribute, old_val)
         self.ldb.set_maxPwdAge(val)
 
     def ch_minPwdLength(self, val):
         self.ldb.set_maxPwdAge(val)
 
     def ch_minPwdLength(self, val):
-        self.logger.info('KDC Minimum Password length was changed from %s to %s' % (self.ldb.get_minPwdLength(), val))
+        old_val = self.ldb.get_minPwdLength()
+        self.logger.info('KDC Minimum Password length was changed from %s to %s' % (old_val, val))
+        self.gp_db.store(str(self), self.attribute, old_val)
         self.ldb.set_minPwdLength(val)
 
     def ch_pwdProperties(self, val):
         self.ldb.set_minPwdLength(val)
 
     def ch_pwdProperties(self, val):
-        self.logger.info('KDC Password Properties were changed from %s to %s' % (self.ldb.get_pwdProperties(), val))
+        old_val = self.ldb.get_pwdProperties()
+        self.logger.info('KDC Password Properties were changed from %s to %s' % (old_val, val))
+        self.gp_db.store(str(self), self.attribute, old_val)
         self.ldb.set_pwdProperties(val)
 
     def nttime2unix(self):
         self.ldb.set_pwdProperties(val)
 
     def nttime2unix(self):
@@ -134,6 +320,9 @@ class inf_to_ldb(inf_to):
 
                }
 
 
                }
 
+    def __str__(self):
+        return 'System Access'
+
 
 class gp_sec_ext(gp_ext):
     '''This class does the following two things:
 
 class gp_sec_ext(gp_ext):
     '''This class does the following two things:
@@ -158,7 +347,7 @@ class gp_sec_ext(gp_ext):
     def listuserpol(self, rootpath):
         return os.path.join(rootpath, "User/Registry.pol")
 
     def listuserpol(self, rootpath):
         return os.path.join(rootpath, "User/Registry.pol")
 
-    def populate_inf(self):
+    def apply_map(self):
         return {"System Access": {"MinimumPasswordAge": ("minPwdAge", inf_to_ldb),
                                   "MaximumPasswordAge": ("maxPwdAge", inf_to_ldb),
                                   "MinimumPasswordLength": ("minPwdLength", inf_to_ldb),
         return {"System Access": {"MinimumPasswordAge": ("minPwdAge", inf_to_ldb),
                                   "MaximumPasswordAge": ("maxPwdAge", inf_to_ldb),
                                   "MinimumPasswordLength": ("minPwdLength", inf_to_ldb),
@@ -168,7 +357,7 @@ class gp_sec_ext(gp_ext):
 
     def read_inf(self, path, conn):
         ret = False
 
     def read_inf(self, path, conn):
         ret = False
-        inftable = self.populate_inf()
+        inftable = self.apply_map()
 
         policy = conn.loadfile(path.replace('/', '\\'))
         current_section = None
 
         policy = conn.loadfile(path.replace('/', '\\'))
         current_section = None
@@ -195,11 +384,13 @@ class gp_sec_ext(gp_ext):
                     (att, setter) = current_section.get(key)
                     value = value.encode('ascii', 'ignore')
                     ret = True
                     (att, setter) = current_section.get(key)
                     value = value.encode('ascii', 'ignore')
                     ret = True
-                    setter(self.logger, self.ldb, self.lp, att, value).update_samba()
+                    setter(self.logger, self.ldb, self.gp_db, self.lp, att, value).update_samba()
+                    self.gp_db.commit()
         return ret
 
         return ret
 
-    def parse(self, afile, ldb, conn, lp):
+    def parse(self, afile, ldb, conn, gp_db, lp):
         self.ldb = ldb
         self.ldb = ldb
+        self.gp_db = gp_db
         self.lp = lp
 
         # Fixing the bug where only some Linux Boxes capitalize MACHINE
         self.lp = lp
 
         # Fixing the bug where only some Linux Boxes capitalize MACHINE
index bba5398571a9b335fca21a17ada489141515129c..721d6071bd10ecc70150989d08a6ec4f1fc30f57 100755 (executable)
@@ -50,6 +50,55 @@ def get_gpo_list(dc_hostname, creds, lp):
         gpos = ads.get_gpo_list(creds.get_username())
     return gpos
 
         gpos = ads.get_gpo_list(creds.get_username())
     return gpos
 
+def apply_gp(lp, creds, test_ldb, logger, store, gp_extensions):
+    gp_db = store.get_gplog(creds.get_username())
+    dc_hostname = get_dc_hostname()
+    try:
+        conn =  smb.SMB(dc_hostname, 'sysvol', lp=lp, creds=creds)
+    except:
+        logger.error('Error connecting to \'%s\' using SMB' % dc_hostname)
+        raise
+    gpos = get_gpo_list(dc_hostname, creds, lp)
+
+    for gpo_obj in gpos:
+        guid = gpo_obj.name
+        if guid == 'Local Policy':
+            continue
+        local_path = os.path.join(lp.get('realm').lower(), 'Policies', guid)
+        version = int(gpo.gpo_get_sysvol_gpt_version(os.path.join(lp.get("path", "sysvol"), local_path))[1])
+        if version != store.get_int(guid):
+            logger.info('GPO %s has changed' % guid)
+            gp_db.set_guid(guid)
+            store.start()
+            try:
+                for ext in gp_extensions:
+                    ext.parse(ext.list(local_path), test_ldb, conn, gp_db, lp)
+            except:
+                logger.error('Failed to parse gpo %s' % guid)
+                store.cancel()
+                continue
+            store.store(guid, '%i' % version)
+        store.commit()
+
+def unapply_log(gp_db):
+    while True:
+        item = gp_db.apply_log_pop()
+        if item:
+            yield item
+        else:
+            break
+
+def unapply_gp(lp, creds, test_ldb, logger, store, gp_extensions):
+    gp_db = store.get_gplog(creds.get_username())
+    for gpo_guid in unapply_log(gp_db):
+        gp_db.set_guid(gpo_guid)
+        unapply_attributes = gp_db.list(gp_extensions)
+        for attr in unapply_attributes:
+            attr_obj = attr[-1](logger, test_ldb, gp_db, lp, attr[0], attr[1])
+            attr_obj.mapper()[attr[0]][0](attr[1]) # Set the old value
+            gp_db.delete(str(attr_obj), attr[0])
+        gp_db.commit()
+
 if __name__ == "__main__":
     parser = optparse.OptionParser('samba_gpoupdate [options]')
     sambaopts = options.SambaOptions(parser)
 if __name__ == "__main__":
     parser = optparse.OptionParser('samba_gpoupdate [options]')
     sambaopts = options.SambaOptions(parser)
@@ -59,6 +108,7 @@ if __name__ == "__main__":
     parser.add_option_group(options.VersionOptions(parser))
     credopts = options.CredentialsOptions(parser)
     parser.add_option('-H', '--url', dest='url', help='URL for the samdb')
     parser.add_option_group(options.VersionOptions(parser))
     credopts = options.CredentialsOptions(parser)
     parser.add_option('-H', '--url', dest='url', help='URL for the samdb')
+    parser.add_option('-X', '--unapply', help='Unapply Group Policy', action='store_true')
     parser.add_option_group(credopts)
 
     # Set the options and the arguments
     parser.add_option_group(credopts)
 
     # Set the options and the arguments
@@ -89,38 +139,16 @@ if __name__ == "__main__":
     elif log_level >= 4:
         logger.setLevel(logging.DEBUG)
 
     elif log_level >= 4:
         logger.setLevel(logging.DEBUG)
 
-    '''Return a live instance of Samba'''
-    test_ldb = SamDB(url, session_info=session, credentials=creds, lp=lp)
-
-    # Read the readable backLog into a hashmap
-    # then open writable backLog in same location
-    sysvol_log = os.path.join(lp.get('cache directory'), 'gpo.tdb')
+    cache_dir = lp.get('cache directory')
+    store = GPOStorage(os.path.join(cache_dir, 'gpo.tdb'))
 
 
-    backlog = Backlog(sysvol_log)
+    gp_extensions = [gp_sec_ext(logger)]
 
 
-    dc_hostname = get_dc_hostname()
-    try:
-        conn =  smb.SMB(dc_hostname, 'sysvol', lp=lp, creds=creds)
-    except:
-        logger.error('Error connecting to \'%s\' using SMB' % dc_hostname)
-        raise
-    gpos = get_gpo_list(dc_hostname, creds, lp)
+    # Get a live instance of Samba
+    test_ldb = SamDB(url, session_info=session, credentials=creds, lp=lp)
 
 
-    for gpo_obj in gpos:
-        guid = gpo_obj.name
-        if guid == 'Local Policy':
-            continue
-        gp_extensions = [gp_sec_ext(logger)]
-        local_path = os.path.join(lp.get('realm').lower(), 'Policies', guid)
-        version = int(gpo.gpo_get_sysvol_gpt_version(os.path.join(lp.get("path", "sysvol"), local_path))[1])
-        if version != backlog.version(guid):
-            logger.info('GPO %s has changed' % guid)
-            try:
-                for ext in gp_extensions:
-                    ext.parse(ext.list(local_path), test_ldb, conn, lp)
-            except:
-                logger.error('Failed to parse gpo %s' % guid)
-                continue
-        backlog.store(guid, version)
-    backlog.commit()
+    if not opts.unapply:
+        apply_gp(lp, creds, test_ldb, logger, store, gp_extensions)
+    else:
+        unapply_gp(lp, creds, test_ldb, logger, store, gp_extensions)