diff --git a/CHANGELOG.md b/CHANGELOG.md
index f2f60e2..2958697 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
## [2.4.0] Unreleased
### Added
+ - Federated login for multiple instances. Defaults to using Hashicorp Vault for secret management.
### Changed
diff --git a/docs/authn.rst b/docs/authn.rst
index c45f4a2..010dc0e 100644
--- a/docs/authn.rst
+++ b/docs/authn.rst
@@ -141,3 +141,41 @@ Authenticating
Once the client side authentication is setup, you should be able to trigger the authentication process with ``!session_start my_secret_word`` which will return a url to complete the login processes. This is how the page looks like:
.. image:: images/authentication_screen.jpg
+
+
+Federated authentication
+------------------------------------------------------------------------
+
+This feature is experimental and may not be suitable for all enviornments that would like to use such a feature.
+
+The federated authentication is intended for use when independent instances of StackStorm are run with an err-stackstorm instance attached to each one.
+
+This creates a problem when users must identify per st2 instance if they want to run an action-alias. To enable scaling the login process, a remote authentication end point is added to err-stackstorm. The endpoint recieves notifications when a chat user successfully authenticates. The receiving err-stackstorm instance, will query a fixed path in the secret store to retreive a one time use token, that will give access to the credentials which will be used to authenticate against the local st2 api instance.
+
+`unwrap_path` string can have a token `{federation_id}` that will be resolved to the id defined in the list of federated peers.
+
+Currently, only vault is supported as a secret backend.
+
+```
+ "secrets_backend": {
+ "vault": {
+ "url": "https://vault.example.com",
+ "auth": {
+ "token": "a.bdcdeed",
+ "approle": {
+ "role_id": "123456",
+ "secret_id": "456789"
+ }
+ },
+ "unwrap_path": "/kv/{federation_id}/err-stackstorm/remote-auth"
+ }
+ },
+ "federation":{
+ "id": "dc2",
+ "peers": {
+ "dc1": {
+ "api": "https://st2.dc1.example.com/"
+ }
+ }
+ },
+```
diff --git a/pyproject.toml b/pyproject.toml
index a043d06..8098ccb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "err-stackstorm"
-version = "2.3.2"
+version = "2.4.0"
authors = [{ name="Err-stackstorm maintainers", email="nzlosh@yahoo.com" }]
keywords = [
"errbot",
@@ -28,6 +28,7 @@ dependencies = [
"requests>=2.20.0",
"sseclient",
"jsonschema",
+ "hvac",
]
[project.urls]
diff --git a/src/err-stackstorm/errst2lib/chat_adapters.py b/src/err-stackstorm/errst2lib/chat_adapters.py
index a4cdfcb..ef3b336 100644
--- a/src/err-stackstorm/errst2lib/chat_adapters.py
+++ b/src/err-stackstorm/errst2lib/chat_adapters.py
@@ -472,3 +472,19 @@ def get_username(self, msg):
else:
name = f"@{username}"
return name
+
+ def normalise_user_id(self, user):
+ return str(user.aclattr)
+
+ def present_sessions(self, sessions):
+ res = "**Sessions**:\n"
+ for session in sessions:
+ userid = session.attributes()["UserID"]
+ if session.attributes().get("UserID") == "errbot%service":
+ res += "- {}\n".format(str(session))
+ else:
+ user = self.bot_plugin.build_identifier(
+ "<@{}>".format(session.attributes().get("UserID"))
+ )
+ res += "- `{}` `{}`\n".format(user.person, str(session))
+ return res
diff --git a/src/err-stackstorm/errst2lib/config.py b/src/err-stackstorm/errst2lib/config.py
index 9857a28..c035d5f 100644
--- a/src/err-stackstorm/errst2lib/config.py
+++ b/src/err-stackstorm/errst2lib/config.py
@@ -3,6 +3,7 @@
from errst2lib.authentication_handler import AuthHandlerFactory
from errst2lib.credentials_adapters import CredentialsFactory
+from errst2lib.secrets_adapters import SecretsAdapterFactory
LOG = logging.getLogger(__name__)
@@ -38,6 +39,9 @@ def setup(self, bot_conf):
self._configure_credentials(bot_conf)
self._configure_rbac_auth(bot_conf)
self._configure_stackstorm(bot_conf)
+ self._configure_secret_backend(bot_conf.STACKSTORM.get("secrets_backend", {}))
+ self._configure_federated_authentication(bot_conf.STACKSTORM.get("federation", {}))
+
self.oob_auth_url = bot_conf.STACKSTORM.get("oob_auth_url", "https://localhost:8888/")
self.timer_update = bot_conf.STACKSTORM.get("timer_update", 60)
self.verify_cert = bot_conf.STACKSTORM.get("verify_cert", True)
@@ -96,3 +100,29 @@ def _configure_credentials(self, bot_conf):
LOG.warning(
"Failed to find any valid st2 credentials for the bot, check configuration file."
)
+
+ def _configure_secret_backend(self, bot_conf):
+ """
+ Get the secret definition.
+ """
+ secret_backend = list(bot_conf.keys())
+ LOG.debug("Secrets backend %s", secret_backend)
+ if len(secret_backend) == 0:
+ LOG.warning(
+ "Unable to configure secrets backend. 'secrets_backend' "
+ "not found in config or no backend defined."
+ )
+ elif len(secret_backend) > 1:
+ LOG.warning(
+ "Only one secret backend definition is supported, "
+ "multiple secret backends have been detected."
+ )
+ else:
+ self.secret_backend = secret_backend[0]
+ self.secret_backend_cfg = bot_conf[self.secret_backend]
+
+ def _configure_federated_authentication(self, bot_conf):
+ """
+ Configuration for Federated authentication.
+ """
+ self.federation = bot_conf
diff --git a/src/err-stackstorm/errst2lib/secrets_adapters.py b/src/err-stackstorm/errst2lib/secrets_adapters.py
new file mode 100644
index 0000000..09f74ca
--- /dev/null
+++ b/src/err-stackstorm/errst2lib/secrets_adapters.py
@@ -0,0 +1,168 @@
+# coding:utf-8
+import abc
+import logging
+
+try:
+ import hvac
+
+ HAVE_HVAC = True
+except ModuleNotFoundError:
+ HAVE_HVAC = False
+
+LOG = logging.getLogger(__name__)
+
+
+class AbstractSecretsAdapterFactory(metaclass=abc.ABCMeta):
+ @abc.abstractmethod
+ def instantiate(store_type):
+ raise NotImplementedError
+
+
+class SecretsAdapterFactory(AbstractSecretsAdapterFactory):
+ @staticmethod
+ def instantiate(store_type):
+ LOG.debug("Establish connection with secret store service. '{}'".format(store_type))
+ return {"vault": VaultSecretsAdapter}.get(store_type, NoOpSecretsAdapter)
+
+
+class AbstractSecretsAdapter(metaclass=abc.ABCMeta):
+ @abc.abstractmethod
+ def setup(self, cfg):
+ raise NotImplementedError
+
+ @abc.abstractmethod
+ def wrap_secret(self, secret, ttl):
+ raise NotImplementedError
+
+ @abc.abstractmethod
+ def unwrap(self, token):
+ raise NotImplementedError
+
+ @abc.abstractmethod
+ def read_kv(self, path, mount_point):
+ raise NotImplementedError
+
+ @abc.abstractmethod
+ def write_kv(self, k, v, mount_point):
+ raise NotImplementedError
+
+ @abc.abstractmethod
+ def renew_token(self):
+ raise NotImplementedError
+
+ @abc.abstractmethod
+ def teardown(self):
+ raise NotImplementedError
+
+
+class NoOpSecretsAdapter(AbstractSecretsAdapter):
+ """
+ No Op Secret Adapater does nothing.
+ """
+
+ def setup(self):
+ pass
+
+ def renew_token(self):
+ pass
+
+ def wrap_secret(self, secret, ttl):
+ pass
+
+ def unwrap(self, token):
+ pass
+
+ def read_kv(self, path, mount_point):
+ pass
+
+ def write_kv(self, k, v, mount_point):
+ pass
+
+ def teardown(self):
+ pass
+
+
+class VaultSecretsAdapter(AbstractSecretsAdapter):
+ """
+ Vault secrets store. kv read/write/list and wrap/unwrap.
+ """
+
+ def __init__(self, cfg):
+ self.client = None
+ self.unwrap_path = None
+ self.cfg = cfg
+
+ def setup(self):
+ if not HAVE_HVAC:
+ LOG.warning(
+ "hvac module could not be found. The Vault Secrets "
+ "adapter can't function without it."
+ )
+ return
+ self.client = hvac.Client(url=self.cfg["url"])
+ self.unwrap_path = self.cfg["unwrap_path"]
+
+ # supported credentials are token or approle.
+ token = self.cfg.get("auth", {}).get("token")
+ if "token" in self.cfg["auth"]:
+ LOG.debug("Vault authentication using Token")
+ self.client.token = self.cfg["auth"]["token"]
+ elif "approle" in self.cfg["auth"]:
+ LOG.debug("Vault authentication using AppRole")
+ self.client.auth.approle.login(
+ role_id=self.cfg["auth"]["approle"].get("role_id"),
+ secret_id=self.cfg["auth"]["approle"].get("secret_id"),
+ )
+ else:
+ raise RuntimeError(
+ "Vault Secret backend only supports token or approle authentication."
+ )
+ LOG.info("Vault token is authenticated = %s", self.client.is_authenticated())
+
+ def renew_token(self, threshold=3600):
+ """
+ threshold: the minimum number of seconds remaining to request a token.
+ """
+ # to do, add threshold check
+ if True:
+ self.teardown()
+ self.setup()
+
+ def get_wrap_path(self):
+ return self.unwrap_path
+
+ def wrap_secret(self, secret, ttl=30):
+ """
+ Given a secret and the path
+ """
+ return self.client.sys.wrap(payload=secret, ttl=ttl)
+
+ def unwrap(self, token):
+ """
+ Given a chat userid, fetch an unwrap token from unwrap_path and perform the unwrap procedure
+ to return the secret contents.
+ """
+ return self.client.sys.unwrap(token)
+
+ def read_kv(self, path, mount_point="kv"):
+ """
+ Read a vault secret from kv path.
+ """
+ return self.client.secrets.kv.v1.read_secret(
+ path=path,
+ mount_point=mount_point,
+ )["data"]
+
+ def write_kv(self, k, v, mount_point="kv"):
+ """
+ Write a vault secret to the kv path.
+ """
+ self.client.secrets.kv.v1.create_or_update_secret(k, secret=v, mount_point="kv")
+
+ def teardown(self):
+ """
+ Close connection with secrets backend.
+ """
+ if self.client:
+ self.client.logout()
+ self.client = None
diff --git a/src/err-stackstorm/errst2lib/session.py b/src/err-stackstorm/errst2lib/session.py
index fcfe870..b3cbed2 100644
--- a/src/err-stackstorm/errst2lib/session.py
+++ b/src/err-stackstorm/errst2lib/session.py
@@ -23,7 +23,7 @@ def __init__(self, user_id, user_secret, session_ttl=3600):
self.bot_secret = None
self.user_id = user_id
self._is_sealed = True
- self.session_id = uuid.uuid4()
+ self.session_id = str(uuid.uuid4())
self.create_date = int(dt.now().timestamp())
self.modified_date = self.create_date
self.ttl_in_seconds = session_ttl
diff --git a/src/err-stackstorm/errst2lib/store_adapters.py b/src/err-stackstorm/errst2lib/store_adapters.py
index af568d9..c125d08 100644
--- a/src/err-stackstorm/errst2lib/store_adapters.py
+++ b/src/err-stackstorm/errst2lib/store_adapters.py
@@ -1,5 +1,6 @@
# coding:utf-8
import abc
+import hashlib
import logging
LOG = logging.getLogger(__name__)
@@ -115,8 +116,16 @@ def put_by_id(self, session_id, session):
self.id_to_user_map[session_id] = session.user_id
def get_by_uuid(self, session_id):
+ """
+ Return a session given its UUID.
+ Return False if the session id is not found.
+ """
user_id = self.id_to_user_map.get(session_id, False)
+ if user_id is False:
+ LOG.debug("Error: Session id '{}' doesn't exist in user mapping.".format(session_id))
+
session = self.memory.get(user_id, False)
if session is False:
- LOG.debug("Error: Session id '{}' points to a missing session.".format(""))
+ LOG.debug("Error: User id '{}' doesn't exist.".format(user_id))
+
return session
diff --git a/src/err-stackstorm/errst2lib/version.py b/src/err-stackstorm/errst2lib/version.py
index ea16adc..f463f49 100644
--- a/src/err-stackstorm/errst2lib/version.py
+++ b/src/err-stackstorm/errst2lib/version.py
@@ -1 +1 @@
-ERR_STACKSTORM_VERSION = "2.3.2"
+ERR_STACKSTORM_VERSION = "2.4.0"
diff --git a/src/err-stackstorm/html/index.html b/src/err-stackstorm/html/index.html
index aa786ff..a608de0 100644
--- a/src/err-stackstorm/html/index.html
+++ b/src/err-stackstorm/html/index.html
@@ -6,7 +6,7 @@