summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorNejc Habjan <hab.nejc@gmail.com>2021-04-18 12:30:03 +0200
committerGitHub <noreply@github.com>2021-04-18 12:30:03 +0200
commitaf781c10db3829163f977e494e4008acf2096d64 (patch)
treeae515156fdad0f28d31ac4bfd8e8bf038d888e8c
parentd2362676d97633893aea27f878773e5fa009976f (diff)
parent91ffb8e97e213d2f14340b952630875995ecedb2 (diff)
downloadgitlab-af781c10db3829163f977e494e4008acf2096d64.tar.gz
Merge pull request #1359 from klorenz/feat_token_lookup
feat(config): allow using a credential helper to lookup tokens
-rw-r--r--docs/cli-usage.rst54
-rw-r--r--gitlab/config.py41
-rw-r--r--gitlab/tests/test_config.py38
3 files changed, 132 insertions, 1 deletions
diff --git a/docs/cli-usage.rst b/docs/cli-usage.rst
index 1c24824..c1b59bf 100644
--- a/docs/cli-usage.rst
+++ b/docs/cli-usage.rst
@@ -48,7 +48,7 @@ example:
[elsewhere]
url = http://else.whe.re:8080
- private_token = CkqsjqcQSFH5FQKDccu4
+ private_token = helper: path/to/helper.sh
timeout = 1
The ``default`` option of the ``[global]`` section defines the GitLab server to
@@ -93,6 +93,8 @@ Only one of ``private_token``, ``oauth_token`` or ``job_token`` should be
defined. If neither are defined an anonymous request will be sent to the Gitlab
server, with very limited permissions.
+We recommend that you use `Credential helpers`_ to securely store your tokens.
+
.. list-table:: GitLab server options
:header-rows: 1
@@ -119,6 +121,56 @@ server, with very limited permissions.
* - ``http_password``
- Password for optional HTTP authentication
+
+Credential helpers
+------------------
+
+For all configuration options that contain secrets (``http_password``,
+``personal_token``, ``oauth_token``, ``job_token``), you can specify
+a helper program to retrieve the secret indicated by a ``helper:``
+prefix. This allows you to fetch values from a local keyring store
+or cloud-hosted vaults such as Bitwarden. Environment variables are
+expanded if they exist and ``~`` expands to your home directory.
+
+It is expected that the helper program prints the secret to standard output.
+To use shell features such as piping to retrieve the value, you will need
+to use a wrapper script; see below.
+
+Example for a `keyring <https://github.com/jaraco/keyring>`_ helper:
+
+.. code-block:: ini
+
+ [global]
+ default = somewhere
+ ssl_verify = true
+ timeout = 5
+
+ [somewhere]
+ url = http://somewhe.re
+ private_token = helper: keyring get Service Username
+ timeout = 1
+
+Example for a `pass <https://www.passwordstore.org>`_ helper with a wrapper script:
+
+.. code-block:: ini
+
+ [global]
+ default = somewhere
+ ssl_verify = true
+ timeout = 5
+
+ [somewhere]
+ url = http://somewhe.re
+ private_token = helper: /path/to/helper.sh
+ timeout = 1
+
+In `/path/to/helper.sh`:
+
+.. code-block:: bash
+
+ #!/bin/bash
+ pass show path/to/password | head -n 1
+
CLI
===
diff --git a/gitlab/config.py b/gitlab/config.py
index 710a354..c663bf8 100644
--- a/gitlab/config.py
+++ b/gitlab/config.py
@@ -17,7 +17,10 @@
import os
import configparser
+import shlex
+import subprocess
from typing import List, Optional, Union
+from os.path import expanduser, expandvars
from gitlab.const import USER_AGENT
@@ -33,6 +36,10 @@ _DEFAULT_FILES: List[str] = _env_config() + [
os.path.expanduser("~/.python-gitlab.cfg"),
]
+HELPER_PREFIX = "helper:"
+
+HELPER_ATTRIBUTES = ["job_token", "http_password", "private_token", "oauth_token"]
+
class ConfigError(Exception):
pass
@@ -50,6 +57,10 @@ class GitlabConfigMissingError(ConfigError):
pass
+class GitlabConfigHelperError(ConfigError):
+ pass
+
+
class GitlabConfigParser(object):
def __init__(
self, gitlab_id: Optional[str] = None, config_files: Optional[List[str]] = None
@@ -150,6 +161,8 @@ class GitlabConfigParser(object):
except Exception:
pass
+ self._get_values_from_helper()
+
self.api_version = "4"
try:
self.api_version = self._config.get("global", "api_version")
@@ -192,3 +205,31 @@ class GitlabConfigParser(object):
self.user_agent = self._config.get(self.gitlab_id, "user_agent")
except Exception:
pass
+
+ def _get_values_from_helper(self):
+ """Update attributes that may get values from an external helper program"""
+ for attr in HELPER_ATTRIBUTES:
+ value = getattr(self, attr)
+ if not isinstance(value, str):
+ continue
+
+ if not value.lower().strip().startswith(HELPER_PREFIX):
+ continue
+
+ helper = value[len(HELPER_PREFIX) :].strip()
+ commmand = [expanduser(expandvars(token)) for token in shlex.split(helper)]
+
+ try:
+ value = (
+ subprocess.check_output(commmand, stderr=subprocess.PIPE)
+ .decode("utf-8")
+ .strip()
+ )
+ except subprocess.CalledProcessError as e:
+ stderr = e.stderr.decode().strip()
+ raise GitlabConfigHelperError(
+ f"Failed to read {attr} value from helper "
+ f"for {self.gitlab_id}:\n{stderr}"
+ ) from e
+
+ setattr(self, attr, value)
diff --git a/gitlab/tests/test_config.py b/gitlab/tests/test_config.py
index 7a9e239..58ccbb0 100644
--- a/gitlab/tests/test_config.py
+++ b/gitlab/tests/test_config.py
@@ -17,6 +17,7 @@
import os
import unittest
+from textwrap import dedent
import mock
import io
@@ -195,6 +196,43 @@ def test_valid_data(m_open, path_exists):
@mock.patch("os.path.exists")
@mock.patch("builtins.open")
+def test_data_from_helper(m_open, path_exists, tmp_path):
+ helper = tmp_path / "helper.sh"
+ helper.write_text(
+ dedent(
+ """\
+ #!/bin/sh
+ echo "secret"
+ """
+ )
+ )
+ helper.chmod(0o755)
+
+ fd = io.StringIO(
+ dedent(
+ """\
+ [global]
+ default = helper
+
+ [helper]
+ url = https://helper.url
+ oauth_token = helper: %s
+ """
+ )
+ % helper
+ )
+
+ fd.close = mock.Mock(return_value=None)
+ m_open.return_value = fd
+ cp = config.GitlabConfigParser(gitlab_id="helper")
+ assert "helper" == cp.gitlab_id
+ assert "https://helper.url" == cp.url
+ assert None == cp.private_token
+ assert "secret" == cp.oauth_token
+
+
+@mock.patch("os.path.exists")
+@mock.patch("builtins.open")
@pytest.mark.parametrize(
"config_string,expected_agent",
[