Skip to content

feat(config): option to configure a helper to lookup the token #1359

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Apr 18, 2021
54 changes: 53 additions & 1 deletion docs/cli-usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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
===

Expand Down
41 changes: 41 additions & 0 deletions gitlab/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -33,6 +36,10 @@ def _env_config() -> List[str]:
os.path.expanduser("~/.python-gitlab.cfg"),
]

HELPER_PREFIX = "helper:"

HELPER_ATTRIBUTES = ["job_token", "http_password", "private_token", "oauth_token"]


class ConfigError(Exception):
pass
Expand All @@ -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
Expand Down Expand Up @@ -150,6 +161,8 @@ def __init__(
except Exception:
pass

self._get_values_from_helper()

self.api_version = "4"
try:
self.api_version = self._config.get("global", "api_version")
Expand Down Expand Up @@ -192,3 +205,31 @@ def __init__(
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)
38 changes: 38 additions & 0 deletions gitlab/tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import os
import unittest
from textwrap import dedent

import mock
import io
Expand Down Expand Up @@ -193,6 +194,43 @@ def test_valid_data(m_open, path_exists):
assert True == cp.ssl_verify


@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(
Expand Down