Repository: privacyidea/privacyidea

Issue:
Federations
Allow several privacyIDEA systems to easily talk to each other a.k.a. forward the authentication request to another PI system in like another department.

This could be achieved by ``remote realm`` but probably rather by policy decorators on ``/validate/check``, which issue another auth request to the federated system, if the user is not found on the local system or the user does not have a token on the local system.


Discussion:
We could define a kind of authentication workflow. 
The initial authentication request is an authentication request to the *local* machine.

Ideally we could stack different blocks of forward requests. Each block would consist of:

* condition: 
  * user does not exist
  * auth failed
  * user has not token
* remote system: The hostname of the remote privacyIDEA system
* remote realm: The realm, in which the user in the remote privacyIDEA system would be authenticated.

Now such a block would return a *respponse*, which in turn could be used as input for the next block.

This way one central privacyIDEA system could "search" or try to authenticate the user in different remote privacyIDEA systems.

Different departments could run their own privacyIDEA system, manage their tokens and users.
The central privacyIDEA system would simply "search" all these remote systems.

### Challenge

It would be great if we could use additional conditions like matching the client IP in the polcies.

### Implementation

I am not sure if we can implement this as a policy. Since we would have to "order" the block, to which remote system to forward the requests.

Maybe we could somehow implement this as event handlers. But this handler would only make sense for  the event ``/validate/check``, unless we would add such a functionality for e.g. creating tokens...

#### Rulesets

We could have rulesets, in which order different services are queried. Each ruleset has conditions and would be only triggered if the conditions match:

Ruleset 1 with conditions:
* ask local
* ask EU
* ask USA

Ruleset 2 with other conditions:
* ask USA
* ask APAC
* ask local

The different conditions could be different client IPs. this way we could determine, that e.g. applications from europe first ask the EU privacyIDEA and applications from USA first ask USA machine.

![20170717_170043](https://user-images.githubusercontent.com/1908620/28274786-3e4db08a-6b12-11e7-80d5-50d5d713d2ac.png)

# Implement Federation using EventHandler

We could implement the forwarding to another privacyIDEA server by adding a **FederationEventHandler**.

## Modifying the Response

At the moment Event Handlers do not modify the ``Response`` object. 
The original API request is triggered here
https://github.com/privacyidea/privacyidea/blob/master/privacyidea/lib/event.py#L60
and returns ``f_result``, which is a ``Response`` object and which is returned by the handler wrapper.

The ``f_result`` is passed as part of the options dictionary. If a handler changes the response object internally, it will be changed in the event wrapper. So there would be no need to modify the ``lib/event.py``.

## Odering event handlers

**Ordering is done**

At the moment the event handler definitions have no ordering.

The function  ``get_handled_events``
https://github.com/privacyidea/privacyidea/blob/master/privacyidea/lib/event.py#L62
would have to return an ordered list.

The admin needs to be able to order the event handler definitions in the UI.
We already have a database column *ordering* (https://github.com/privacyidea/privacyidea/blob/master/privacyidea/models.py#L1539) which is not used, yet. We could fill this in the UI and the ``get_handled_events`` could return the ordered list.

lower numbers go first.

The ordered list should also be displayed in the UI.
 
## Using the Request data

The request data in the ``do`` method of the FederationsHandler could be used to create a new request which is sent to the child privacyIDEA system.
The response of the child privacyIDEA system would overwrite the response.

We need to check, if we need to take care, since some response data might not exist on the parant privacyIDEA system. This might lead to unexpected errors, since a user in the response could not be resolved.

## Handler options

Several handler actions with the conditions options could define the behaviour:

* action: **forward** to child privacyIDEA server
  * option (required) **URL** (string)
  * change **realm** to: ... (string)
  * change **resolver** to:... (string)
  * Do not check SSL (boolean) (Yes/No)

Would we need additional actions besides the **forward**.?

## Handler conditions

Additional handler conditions?

* Client IP

## Interesting side effect

The FederationHandler can also be used for other API calls like enrolling a token or disabling a token.
This way, a token could be enrolled on a child privacyIDEA system or tokens could be disabled on the parent system and all children...

This might also be used to separate the actual UI and the backend. See #605 


Resolving patch:
diff --git a/privacyidea/api/event.py b/privacyidea/api/event.py
--- a/privacyidea/api/event.py
+++ b/privacyidea/api/event.py
@@ -65,7 +65,7 @@ def get_eventhandling(eventid=None):
         res = AVAILABLE_EVENTS
     elif eventid == "handlermodules":
         # TODO: We need to provide a dynamic list of event handlers
-        res = ["UserNotification", "Token", "Script"]
+        res = ["UserNotification", "Token", "Federation", "Script"]
     else:
         res = g.event_config.get_event(eventid)
     g.audit_object.log({"success": True})
diff --git a/privacyidea/lib/event.py b/privacyidea/lib/event.py
--- a/privacyidea/lib/event.py
+++ b/privacyidea/lib/event.py
@@ -92,6 +92,8 @@ def event_wrapper(*args, **kwds):
 
                     event_handler.do(e_handler_def.get("action"),
                                      options=options)
+                    # In case the handler has modified the response
+                    f_result = options.get("response")
                     # set audit object to success
                     event_audit.log({"success": True})
                     event_audit.finalize_log()
@@ -114,6 +116,8 @@ def get_handler_object(handlername):
         UserNotificationEventHandler
     from privacyidea.lib.eventhandler.tokenhandler import TokenEventHandler
     from privacyidea.lib.eventhandler.scripthandler import ScriptEventHandler
+    from privacyidea.lib.eventhandler.federationhandler import \
+        FederationEventHandler
     h_obj = None
     if handlername == "UserNotification":
         h_obj = UserNotificationEventHandler()
@@ -121,6 +125,8 @@ def get_handler_object(handlername):
         h_obj = TokenEventHandler()
     if handlername == "Script":
         h_obj = ScriptEventHandler()
+    if handlername == "Federation":
+        h_obj = FederationEventHandler()
     return h_obj
 
 
diff --git a/privacyidea/lib/eventhandler/federationhandler.py b/privacyidea/lib/eventhandler/federationhandler.py
new file mode 100644
--- /dev/null
+++ b/privacyidea/lib/eventhandler/federationhandler.py
@@ -0,0 +1,163 @@
+# -*- coding: utf-8 -*-
+#
+#  2017-08-23 Cornelius Kölbel <cornelius.koelbel@netknights.it>
+#             Initial federation handler
+#
+# License:  AGPLv3
+# (c) 2017. Cornelius Kölbel
+#
+# This code is free software; you can redistribute it and/or
+# modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
+# License as published by the Free Software Foundation; either
+# version 3 of the License, or any later version.
+#
+# This code is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+#
+# You should have received a copy of the GNU Affero General Public
+# License along with this program.  If not, see <http://www.gnu.org/licenses/>.
+#
+#
+__doc__ = """This is the event handler module for privacyIDEA federations.
+Requests can be forwarded to other privacyIDEA servers.
+"""
+from privacyidea.lib.eventhandler.base import BaseEventHandler
+from privacyidea.lib.privacyideaserver import (get_privacyideaservers,
+                                               get_privacyideaserver)
+from privacyidea.lib import _
+import json
+import logging
+import requests
+from flask import Response
+
+
+log = logging.getLogger(__name__)
+
+
+class ACTION_TYPE(object):
+    """
+    Allowed actions
+    """
+    FORWARD = "forward"
+
+
+class FederationEventHandler(BaseEventHandler):
+    """
+    An Eventhandler needs to return a list of actions, which it can handle.
+
+    It also returns a list of allowed action and conditions
+
+    It returns an identifier, which can be used in the eventhandlig definitions
+    """
+
+    identifier = "Federation"
+    description = "This event handler can forward the request to other " \
+                  "privacyIDEA servers"
+
+    @property
+    def actions(cls):
+        """
+        This method returns a dictionary of allowed actions and possible
+        options in this handler module.
+
+        :return: dict with actions
+        """
+        pi_servers = [x.config.identifier for x in get_privacyideaservers()]
+        actions = {ACTION_TYPE.FORWARD:
+                       {"privacyIDEA":
+                            {"type": "str",
+                             "required": True,
+                             "value": pi_servers,
+                             "description": _("The remote/child privacyIDEA "
+                                              "Server.")
+                             },
+                        "realm":
+                            {"type": "str",
+                             "description": _("Change the realm name to a "
+                                              "realm on the child privacyIDEA "
+                                              "system.")
+                            },
+                        "resolver":
+                            {"type": "str",
+                             "description": _("Change the resolver name to a "
+                                              "resolver on the child "
+                                              "privacyIDEA system.")
+                            },
+                        "forward_client_ip":
+                            {"type": "bool",
+                             "description": _("Forward the client IP to the "
+                                              "child privacyIDEA server. "
+                                              "Otherwise this server will "
+                                              "be the client.")
+                            }
+                        }
+                   }
+        return actions
+
+    def do(self, action, options=None):
+        """
+        This method executes the defined action in the given event.
+
+        :param action:
+        :param options: Contains the flask parameters g, request, response
+            and the handler_def configuration
+        :type options: dict
+        :return:
+        """
+        g = options.get("g")
+        request = options.get("request")
+        handler_def = options.get("handler_def")
+        handler_options = handler_def.get("options", {})
+
+        if action == ACTION_TYPE.FORWARD:
+            server_def = handler_options.get("privacyIDEA")
+            pi_server = get_privacyideaserver(server_def)
+
+            # the new url is the configured server url and the original path
+            url = pi_server.config.url + request.path
+            # We use the originial method
+            method = request.method
+            tls = pi_server.config.tls
+            # We also transfer the original payload
+            data = request.all_data
+            if handler_options.get("forward_client_ip"):
+                data["client"] = g.client_ip
+            if handler_options.get("realm"):
+                data["realm"] = handler_options.get("realm")
+            if handler_options.get("resolver"):
+                data["resolver"] = handler_options.get("resolver")
+
+            # TODO: We need to pass an authorization header if we forward
+            # administrative requests
+            log.info(u"Sending {0} request to {1!r}".format(method, url))
+            requestor = None
+            params = None
+
+            if method.upper() == "GET":
+                params = data
+                data = None
+                requestor = requests.get
+            elif method.upper() == "POST":
+                requestor = requests.post
+            elif method.upper() == "DELETE":
+                requestor = requests.delete
+
+            if requestor:
+                r = requestor(url, params=params, data=data, verify=tls)
+                # convert requests Response to werkzeug Response
+                response_dict = json.loads(r.text)
+                if "detail" in response_dict:
+                    response_dict.get("detail", {})["origin"] = url
+                # We will modify the response!
+                # We can not use flask.jsonify(response_dict) here, since we
+                # would work outside of application context!
+                options["response"] = Response()
+                options["response"].content_type = "application/json"
+                options["response"].data = json.dumps(response_dict)
+            else:
+                log.warning(u"Unsupported method: {0!r}".format(method))
+
+        return True
+


Test patch:
diff --git a/tests/test_lib_events.py b/tests/test_lib_events.py
--- a/tests/test_lib_events.py
+++ b/tests/test_lib_events.py
@@ -6,12 +6,14 @@
 """
 
 import smtpmock
+import responses
 from .base import MyTestCase, FakeFlaskG, FakeAudit
 from privacyidea.lib.eventhandler.usernotification import (
     UserNotificationEventHandler, NOTIFY_TYPE)
 from privacyidea.lib.eventhandler.tokenhandler import (TokenEventHandler,
                                                        ACTION_TYPE, VALIDITY)
 from privacyidea.lib.eventhandler.scripthandler import ScriptEventHandler
+from privacyidea.lib.eventhandler.federationhandler import FederationEventHandler
 from privacyidea.lib.eventhandler.base import BaseEventHandler, CONDITION
 from privacyidea.lib.smtpserver import add_smtpserver
 from privacyidea.lib.smsprovider.SMSProvider import set_smsgateway
@@ -28,6 +30,7 @@
 from privacyidea.lib.tokenclass import DATE_FORMAT
 from privacyidea.lib.user import create_user, User
 from privacyidea.lib.policy import ACTION
+from privacyidea.lib.error import ParameterError
 from datetime import datetime, timedelta
 from dateutil.parser import parse as parse_date_string
 from dateutil.tz import tzlocal
@@ -43,7 +46,7 @@ def test_01_create_update_delete(self):
         self.assertEqual(eid, 1)
 
         # create a new event!
-        r = set_event("name2", "token_init, token_assign",
+        r = set_event("name2", ["token_init", "token_assign"],
                       "UserNotification", "sendmail",
                       conditions={},
                       options={"emailconfig": "themis",
@@ -85,6 +88,16 @@ def test_01_create_update_delete(self):
         events = event_config.get_handled_events("token_init")
         self.assertEqual(len(events), 1)
 
+        # If eventid is None, then the whole list is returned
+        r = event_config.get_event(None)
+        self.assertEqual(r, event_config.events)
+        # return a destinct eventid
+        r = event_config.get_event(events[0].get("id"))
+        self.assertEqual(r[0].get("id"), events[0].get("id"))
+
+        # We can not enable an event, that does not exist.
+        self.assertRaises(ParameterError, enable_event, 1234567, True)
+
         # Cleanup
         r = delete_event(n_eid)
         self.assertTrue(r)
@@ -95,6 +108,15 @@ def test_02_get_handler_object(self):
         h_obj = get_handler_object("UserNotification")
         self.assertEqual(type(h_obj), UserNotificationEventHandler)
 
+        h_obj = get_handler_object("Token")
+        self.assertEqual(type(h_obj), TokenEventHandler)
+
+        h_obj = get_handler_object("Script")
+        self.assertEqual(type(h_obj), ScriptEventHandler)
+
+        h_obj = get_handler_object("Federation")
+        self.assertEqual(type(h_obj), FederationEventHandler)
+
 
 class BaseEventHandlerTestCase(MyTestCase):
 
@@ -446,6 +468,187 @@ def test_01_runscript(self):
         remove_token("SPASS01")
 
 
+class FederationEventTestCase(MyTestCase):
+
+    def test_00_static_actions(self):
+        from privacyidea.lib.eventhandler.federationhandler import ACTION_TYPE
+        actions = FederationEventHandler().actions
+        self.assertTrue(ACTION_TYPE.FORWARD in actions)
+
+    @responses.activate
+    def test_01_forward(self):
+        # setup realms
+        self.setUp_user_realms()
+        self.setUp_user_realm2()
+
+        g = FakeFlaskG()
+        audit_object = FakeAudit()
+        g.audit_object = audit_object
+
+        # An authentication request for user root with a password, which does
+        #  not exist on the local privacyIDEA system
+        builder = EnvironBuilder(method='POST',
+                                 data={'user': "root", "pass": "lakjsiqdf"},
+                                 headers={})
+        env = builder.get_environ()
+        env["REMOTE_ADDR"] = "10.0.0.1"
+        g.client_ip = env["REMOTE_ADDR"]
+        req = Request(env)
+        req.all_data = {'user': "root", "pass": "lakjsiqdf"}
+        req.path = "/validate/check"
+        resp = Response()
+        options = {"g": g,
+                   "request": req,
+                   "response": resp,
+                   "handler_def": {"options":
+                                       {"realm": "xyz",
+                                        "resolver": "resoremote",
+                                        "forward_client_ip": True,
+                                        "privacyIDEA": "remotePI"}
+                                   }
+                   }
+        f_handler = FederationEventHandler()
+        from privacyidea.lib.eventhandler.federationhandler import ACTION_TYPE
+        from privacyidea.lib.privacyideaserver import add_privacyideaserver
+        responses.add(responses.POST, "https://remote/validate/check",
+                      body="""{
+                        "jsonrpc": "2.0",
+                        "detail": {},
+                        "version": "privacyIDEA 2.20.dev2",
+                        "result": {
+                          "status": true,
+                          "value": true},
+                        "time": 1503561105.028947,
+                        "id": 1
+                        }""",
+                      content_type="application/json",
+                      )
+        add_privacyideaserver("remotePI", url="https://remote", tls=False)
+        res = f_handler.do(ACTION_TYPE.FORWARD, options=options)
+        self.assertTrue(res)
+        response = json.loads(options.get("response").data)
+        self.assertEqual(response.get("detail").get("origin"),
+                         "https://remote/validate/check")
+
+        # The same with a GET Request
+        builder = EnvironBuilder(method='GET',
+                                 data={'user': "root", "pass": "lakjsiqdf"},
+                                 headers={})
+        env = builder.get_environ()
+        env["REMOTE_ADDR"] = "10.0.0.1"
+        g.client_ip = env["REMOTE_ADDR"]
+        req = Request(env)
+        req.all_data = {'user': "root", "pass": "lakjsiqdf"}
+        req.path = "/validate/check"
+        resp = Response()
+        options = {"g": g,
+                   "request": req,
+                   "response": resp,
+                   "handler_def": {"options":
+                                       {"realm": "xyz",
+                                        "forward_client_ip": True,
+                                        "privacyIDEA": "remotePI"}
+                                   }
+                   }
+        responses.add(responses.GET, "https://remote/validate/check",
+                      body="""{
+                                "jsonrpc": "2.0",
+                                "detail": {},
+                                "version": "privacyIDEA 2.20.dev2",
+                                "result": {
+                                  "status": true,
+                                  "value": true},
+                                "time": 1503561105.028947,
+                                "id": 1
+                                }""",
+                      content_type="application/json",
+                      )
+        add_privacyideaserver("remotePI", url="https://remote", tls=False)
+        res = f_handler.do(ACTION_TYPE.FORWARD, options=options)
+        self.assertTrue(res)
+        response = json.loads(options.get("response").data)
+        self.assertEqual(response.get("detail").get("origin"),
+                         "https://remote/validate/check")
+
+        # The same with a DELETE Request
+        builder = EnvironBuilder(method='DELETE',
+                                 headers={})
+        env = builder.get_environ()
+        env["REMOTE_ADDR"] = "10.0.0.1"
+        g.client_ip = env["REMOTE_ADDR"]
+        req = Request(env)
+        req.all_data = {}
+        req.path = "/token/serial"
+        resp = Response()
+        options = {"g": g,
+                   "request": req,
+                   "response": resp,
+                   "handler_def": {"options":
+                                       {"realm": "xyz",
+                                        "forward_client_ip": True,
+                                        "privacyIDEA": "remotePI"}
+                                   }
+                   }
+        responses.add(responses.DELETE, "https://remote/token/serial",
+                      body="""{
+                                        "jsonrpc": "2.0",
+                                        "detail": {},
+                                        "version": "privacyIDEA 2.20.dev2",
+                                        "result": {
+                                          "status": true,
+                                          "value": true},
+                                        "time": 1503561105.028947,
+                                        "id": 1
+                                        }""",
+                      content_type="application/json",
+                      )
+        add_privacyideaserver("remotePI", url="https://remote", tls=False)
+        res = f_handler.do(ACTION_TYPE.FORWARD, options=options)
+        self.assertTrue(res)
+        response = json.loads(options.get("response").data)
+        self.assertEqual(response.get("detail").get("origin"),
+                         "https://remote/token/serial")
+
+        # The same with an unsupported Request method
+        builder = EnvironBuilder(method='PUT',
+                                 data={'user': "root", "pass": "lakjsiqdf"},
+                                 headers={})
+        env = builder.get_environ()
+        env["REMOTE_ADDR"] = "10.0.0.1"
+        g.client_ip = env["REMOTE_ADDR"]
+        req = Request(env)
+        req.all_data = {'user': "root", "pass": "lakjsiqdf"}
+        req.path = "/token"
+        resp = Response()
+        options = {"g": g,
+                   "request": req,
+                   "response": resp,
+                   "handler_def": {"options":
+                                       {"realm": "xyz",
+                                        "forward_client_ip": True,
+                                        "privacyIDEA": "remotePI"}
+                                   }
+                   }
+        responses.add(responses.PUT, "https://remote/token",
+                      body="""{
+                                        "jsonrpc": "2.0",
+                                        "detail": {},
+                                        "version": "privacyIDEA 2.20.dev2",
+                                        "result": {
+                                          "status": true,
+                                          "value": true},
+                                        "time": 1503561105.028947,
+                                        "id": 1
+                                        }""",
+                      content_type="application/json",
+                      )
+        add_privacyideaserver("remotePI", url="https://remote", tls=False)
+        res = f_handler.do(ACTION_TYPE.FORWARD, options=options)
+        self.assertTrue(res)
+        # No Response data, since this method is not supported
+        self.assertEqual(options.get("response").data, "")
+
+
 class TokenEventTestCase(MyTestCase):
 
     def test_01_set_tokenrealm(self):
diff --git a/tests/testdata/testuser.sqlite b/tests/testdata/testuser.sqlite
Binary files a/tests/testdata/testuser.sqlite and b/tests/testdata/testuser.sqlite differ