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 @@