Skip to content

Commit

Permalink
Merge pull request #1359 from klorenz/feat_token_lookup
Browse files Browse the repository at this point in the history
feat(config): allow using a credential helper to lookup tokens
  • Loading branch information
nejch committed Apr 18, 2021
2 parents d236267 + 91ffb8e commit af781c1
Show file tree
Hide file tree
Showing 3 changed files with 132 additions and 1 deletion.
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

0 comments on commit af781c1

Please sign in to comment.