From 341eeabdfbe92a52b68a997cbfad8df33689dc83 Mon Sep 17 00:00:00 2001
From: William Brown <william@blackhats.net.au>
Date: Jan 28 2019 22:40:35 +0000
Subject: Ticket 50151 - lib389 support cli add/replace/delete on objects


Bug Description: We need a generic way to add/replace/delete on
objects, that is not ldif. Ldif is wildly inaccessible and hard
to use.

Fix Description: Add a "modify" generic to cli_base, that is
used by user. It supports a syntax of:

modify <selector> <add|replace|delete>:<attr>:<value>

An example is:

... user modify demo_user add:objectclass:nsMemberOf

These can have many modifications in a single transaction:

user modify demo_user add:objectclass:nsMemberOf add:description:test

https://pagure.io/389-ds-base/issue/50151

Author: William Brown <william@blackhats.net.au>

Review by: spichugi, mreynolds, lkrispen (Thanks!)

---

diff --git a/src/lib389/lib389/_mapped_object.py b/src/lib389/lib389/_mapped_object.py
index 06a2821..bc0c8e6 100644
--- a/src/lib389/lib389/_mapped_object.py
+++ b/src/lib389/lib389/_mapped_object.py
@@ -388,11 +388,15 @@ class DSLdapObject(DSLogging):
             if len(mod) < 2:
                 # Error
                 raise ValueError('Not enough arguments in the mod op')
-            elif len(mod) == 2:  # delete all attributes action
+            elif len(mod) == 2:  # no action
+                # This hack exists because the original lib389 Entry type
+                # does odd things.
                 action, key = mod
                 if action != ldap.MOD_DELETE:
-                    raise ValueError('Not enough arguments in the mod op')
-                mod_list.append((action, key, None))
+                    raise ValueError('Only MOD_DELETE takes two arguments %s' % mod)
+                value = None
+                # Just add the raw mod, because we don't have a value
+                mod_list.append((action, key, value))
             elif len(mod) == 3:
                 action, key, value = mod
                 if action != ldap.MOD_REPLACE and \
diff --git a/src/lib389/lib389/cli_base/__init__.py b/src/lib389/lib389/cli_base/__init__.py
index 3e2cdcc..14cb999 100644
--- a/src/lib389/lib389/cli_base/__init__.py
+++ b/src/lib389/lib389/cli_base/__init__.py
@@ -271,6 +271,47 @@ def _generic_del_attr(inst, basedn, log, manager_class, args=None):
         # Missing value
         raise ValueError("Missing attribute to delete")
 
+def _generic_modify_change_to_mod(change):
+    values = change.split(":")
+    if len(values) <= 2:
+        raise ValueError("Not enough arguments in '%s'. action:attribute:value" % change)
+    elif len(values) >= 4:
+        raise ValueError("Too many arguments in '%s'. %s:attribute:value expected" % (change, values[0]))
+    elif len(values[1]) == 0:
+        raise ValueError("Invalid empty attribute name in '%s'." % change)
+    # Return a tuple
+    if values[0] == 'add':
+        if len(values[2]) == 0:
+            raise ValueError("Invalid empty value in '%s'." % change)
+        return (ldap.MOD_ADD, values[1], values[2])
+    elif values[0] == 'delete':
+        if len(values[2]) == 0:
+            return (ldap.MOD_DELETE, values[1])
+        return (ldap.MOD_DELETE, values[1], values[2])
+    elif values[0] == 'replace':
+        if len(values[2]) == 0:
+            raise ValueError("Invalid empty value in '%s'." % change)
+        return (ldap.MOD_REPLACE, values[1], values[2])
+    else:
+        raise ValueError("Unknown action '%s'. Expected add, delete, replace" % change)
+
+def _generic_modify(inst, basedn, log, manager_class, selector, args=None):
+    # Here, we should have already selected the type etc. mc should be a
+    # type of DSLdapObject (singular)
+    mc = manager_class(inst, basedn)
+    # Get the object
+    if args and args.changes:
+        o = mc.get(selector)
+        # Now parse the series of arguments.
+        # Turn them into mod lists. See apply_mods.
+        mods = [_generic_modify_change_to_mod(x) for x in args.changes]
+        log.debug("Requested mods: %s" % mods)
+        # Now push them to dsldapobject to modify
+        o.apply_mods(mods)
+        print('Successfully modified %s' % o.dn)
+    else:
+        raise ValueError("Missing modify actions to perform.")
+
 
 class LogCapture(logging.Handler):
     """
diff --git a/src/lib389/lib389/cli_idm/user.py b/src/lib389/lib389/cli_idm/user.py
index 7de9275..9cd5005 100644
--- a/src/lib389/lib389/cli_idm/user.py
+++ b/src/lib389/lib389/cli_idm/user.py
@@ -8,7 +8,7 @@
 
 import argparse
 from lib389.idm.user import nsUserAccount, nsUserAccounts
-from lib389.cli_base import populate_attr_arguments
+from lib389.cli_base import populate_attr_arguments, _generic_modify
 from lib389.cli_idm import (
     _generic_list,
     _generic_get,
@@ -48,6 +48,10 @@ def delete(inst, basedn, log, args, warn=True):
         _warn(dn, msg="Deleting %s %s" % (SINGULAR.__name__, dn))
     _generic_delete(inst, basedn, log.getChild('_generic_delete'), SINGULAR, dn, args)
 
+def modify(inst, basedn, log, args, warn=True):
+    rdn = _get_arg( args.selector, msg="Enter %s to retrieve" % RDN)
+    _generic_modify(inst, basedn, log.getChild('_generic_modify'), MANY, rdn, args)
+
 def status(inst, basedn, log, args):
     uid = _get_arg( args.uid, msg="Enter %s to check" % RDN)
     uas = MANY(inst, basedn)
@@ -90,6 +94,11 @@ def create_parser(subparsers):
     create_parser.set_defaults(func=create)
     populate_attr_arguments(create_parser, SINGULAR._must_attributes)
 
+    modify_parser = subcommands.add_parser('modify', help='modify <add|delete|replace>:<attribute>:<value> ...')
+    modify_parser.set_defaults(func=modify)
+    modify_parser.add_argument('selector', nargs=1, help='The uid to modify')
+    modify_parser.add_argument('changes', nargs='+', help="A list of changes to apply in format: <add|delete|replace>:<attribute>:<value>")
+
     delete_parser = subcommands.add_parser('delete', help='deletes the object')
     delete_parser.set_defaults(func=delete)
     delete_parser.add_argument('dn', nargs='?', help='The dn to delete')
diff --git a/src/lib389/lib389/tests/cli/idm_user_modify_test.py b/src/lib389/lib389/tests/cli/idm_user_modify_test.py
new file mode 100644
index 0000000..0ab3269
--- /dev/null
+++ b/src/lib389/lib389/tests/cli/idm_user_modify_test.py
@@ -0,0 +1,95 @@
+# --- BEGIN COPYRIGHT BLOCK ---
+# Copyright (C) 2019 William Brown <william@blackhats.net.au>
+# All rights reserved.
+#
+# License: GPL (version 3 or any later version).
+# See LICENSE for details.
+# --- END COPYRIGHT BLOCK ---
+
+import pytest
+import ldap
+
+from lib389._constants import DEFAULT_SUFFIX, INSTALL_LATEST_CONFIG
+
+from lib389.cli_conf.backend import backend_create
+from lib389.cli_idm.initialise import initialise
+from lib389.cli_idm.user import create, modify
+
+from lib389.cli_base import LogCapture, FakeArgs
+from lib389.tests.cli import topology
+
+from lib389.utils import ds_is_older
+pytestmark = pytest.mark.skipif(ds_is_older('1.4.0'), reason="Not implemented")
+
+# Topology is pulled from __init__.py
+def test_user_modify(topology):
+    be_args = FakeArgs()
+
+    be_args.be_name = 'userRoot'
+    be_args.suffix = DEFAULT_SUFFIX
+    be_args.parent_suffix = None
+    be_args.create_entries = False
+    backend_create(topology.standalone, None, topology.logcap.log, be_args)
+
+    # And add the skeleton objects.
+    init_args = FakeArgs()
+    init_args.version = INSTALL_LATEST_CONFIG
+    initialise(topology.standalone, DEFAULT_SUFFIX, topology.logcap.log, init_args)
+
+    # Check that our modify parser works. Modify statements are such as:
+    # "add:attr:value". Replace is the exception as "replace:attr:old:new"
+
+    # Check bad syntax
+    modify_args = FakeArgs()
+    modify_args.selector = "demo_user"
+    modify_args.changes = ["tnaohtnsuahtnsouhtns"]
+
+    with pytest.raises(ValueError):
+        modify(topology.standalone, DEFAULT_SUFFIX, topology.logcap.log, modify_args)
+
+    modify_args.changes = ["add:attr:"]
+    with pytest.raises(ValueError):
+        modify(topology.standalone, DEFAULT_SUFFIX, topology.logcap.log, modify_args)
+
+    modify_args.changes = ["add:attr"]
+    with pytest.raises(ValueError):
+        modify(topology.standalone, DEFAULT_SUFFIX, topology.logcap.log, modify_args)
+
+    modify_args.changes = ["replace::"]
+    with pytest.raises(ValueError):
+        modify(topology.standalone, DEFAULT_SUFFIX, topology.logcap.log, modify_args)
+
+    modify_args.changes = ["replace:attr::new"]
+    with pytest.raises(ValueError):
+        modify(topology.standalone, DEFAULT_SUFFIX, topology.logcap.log, modify_args)
+
+    modify_args.changes = ["delete:attr:old:new"]
+    with pytest.raises(ValueError):
+        modify(topology.standalone, DEFAULT_SUFFIX, topology.logcap.log, modify_args)
+
+    # Check that even a single bad value causes error
+    modify_args.changes = ["add:description:goodvalue", "add:attr:"]
+    with pytest.raises(ValueError):
+        modify(topology.standalone, DEFAULT_SUFFIX, topology.logcap.log, modify_args)
+
+    # check good syntax
+    modify_args.changes = ["add:description:testvalue"]
+    modify(topology.standalone, DEFAULT_SUFFIX, topology.logcap.log, modify_args)
+
+    modify_args.changes = ["replace:description:newvalue"]
+    modify(topology.standalone, DEFAULT_SUFFIX, topology.logcap.log, modify_args)
+
+    modify_args.changes = ["delete:description:newvalue"]
+    modify(topology.standalone, DEFAULT_SUFFIX, topology.logcap.log, modify_args)
+
+    modify_args.changes = ["add:description:testvalue"]
+    modify(topology.standalone, DEFAULT_SUFFIX, topology.logcap.log, modify_args)
+
+    modify_args.changes = ["delete:description:"]
+    modify(topology.standalone, DEFAULT_SUFFIX, topology.logcap.log, modify_args)
+
+    # check mixed type, with multiple actions
+
+    modify_args.changes = ["add:objectclass:nsMemberOf", "add:description:anothervalue"]
+    modify(topology.standalone, DEFAULT_SUFFIX, topology.logcap.log, modify_args)
+
diff --git a/src/lib389/lib389/tests/cli/idm_user_test.py b/src/lib389/lib389/tests/cli/idm_user_test.py
index fdbc17d..8d12737 100644
--- a/src/lib389/lib389/tests/cli/idm_user_test.py
+++ b/src/lib389/lib389/tests/cli/idm_user_test.py
@@ -1,5 +1,6 @@
 # --- BEGIN COPYRIGHT BLOCK ---
 # Copyright (C) 2017 Red Hat, Inc.
+# Copyright (C) 2019 William Brown <william@blackhats.net.au>
 # All rights reserved.
 #
 # License: GPL (version 3 or any later version).
@@ -25,8 +26,10 @@ pytestmark = pytest.mark.skipif(ds_is_older('1.4.0'), reason="Not implemented")
 def test_user_tasks(topology):
     be_args = FakeArgs()
 
-    be_args.cn = 'userRoot'
-    be_args.nsslapd_suffix = DEFAULT_SUFFIX
+    be_args.be_name = 'userRoot'
+    be_args.suffix = DEFAULT_SUFFIX
+    be_args.parent_suffix = None
+    be_args.create_entries = False
     backend_create(topology.standalone, None, topology.logcap.log, be_args)
 
     # And add the skeleton objects.
@@ -38,6 +41,7 @@ def test_user_tasks(topology):
     topology.logcap.flush()
     u_args = FakeArgs()
     u_args.selector = 'testuser'
+    u_args.json = False
     with pytest.raises(ldap.NO_SUCH_OBJECT):
         get(topology.standalone, DEFAULT_SUFFIX, topology.logcap.log, u_args)
 

