Skip to content

Commit af781c1

Browse files
authored
Merge pull request #1359 from klorenz/feat_token_lookup
feat(config): allow using a credential helper to lookup tokens
2 parents d236267 + 91ffb8e commit af781c1

File tree

3 files changed

+132
-1
lines changed

3 files changed

+132
-1
lines changed

docs/cli-usage.rst

+53-1
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ example:
4848
4949
[elsewhere]
5050
url = http://else.whe.re:8080
51-
private_token = CkqsjqcQSFH5FQKDccu4
51+
private_token = helper: path/to/helper.sh
5252
timeout = 1
5353
5454
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
9393
defined. If neither are defined an anonymous request will be sent to the Gitlab
9494
server, with very limited permissions.
9595

96+
We recommend that you use `Credential helpers`_ to securely store your tokens.
97+
9698
.. list-table:: GitLab server options
9799
:header-rows: 1
98100

@@ -119,6 +121,56 @@ server, with very limited permissions.
119121
* - ``http_password``
120122
- Password for optional HTTP authentication
121123

124+
125+
Credential helpers
126+
------------------
127+
128+
For all configuration options that contain secrets (``http_password``,
129+
``personal_token``, ``oauth_token``, ``job_token``), you can specify
130+
a helper program to retrieve the secret indicated by a ``helper:``
131+
prefix. This allows you to fetch values from a local keyring store
132+
or cloud-hosted vaults such as Bitwarden. Environment variables are
133+
expanded if they exist and ``~`` expands to your home directory.
134+
135+
It is expected that the helper program prints the secret to standard output.
136+
To use shell features such as piping to retrieve the value, you will need
137+
to use a wrapper script; see below.
138+
139+
Example for a `keyring <https://github.com/jaraco/keyring>`_ helper:
140+
141+
.. code-block:: ini
142+
143+
[global]
144+
default = somewhere
145+
ssl_verify = true
146+
timeout = 5
147+
148+
[somewhere]
149+
url = http://somewhe.re
150+
private_token = helper: keyring get Service Username
151+
timeout = 1
152+
153+
Example for a `pass <https://www.passwordstore.org>`_ helper with a wrapper script:
154+
155+
.. code-block:: ini
156+
157+
[global]
158+
default = somewhere
159+
ssl_verify = true
160+
timeout = 5
161+
162+
[somewhere]
163+
url = http://somewhe.re
164+
private_token = helper: /path/to/helper.sh
165+
timeout = 1
166+
167+
In `/path/to/helper.sh`:
168+
169+
.. code-block:: bash
170+
171+
#!/bin/bash
172+
pass show path/to/password | head -n 1
173+
122174
CLI
123175
===
124176

gitlab/config.py

+41
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@
1717

1818
import os
1919
import configparser
20+
import shlex
21+
import subprocess
2022
from typing import List, Optional, Union
23+
from os.path import expanduser, expandvars
2124

2225
from gitlab.const import USER_AGENT
2326

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

39+
HELPER_PREFIX = "helper:"
40+
41+
HELPER_ATTRIBUTES = ["job_token", "http_password", "private_token", "oauth_token"]
42+
3643

3744
class ConfigError(Exception):
3845
pass
@@ -50,6 +57,10 @@ class GitlabConfigMissingError(ConfigError):
5057
pass
5158

5259

60+
class GitlabConfigHelperError(ConfigError):
61+
pass
62+
63+
5364
class GitlabConfigParser(object):
5465
def __init__(
5566
self, gitlab_id: Optional[str] = None, config_files: Optional[List[str]] = None
@@ -150,6 +161,8 @@ def __init__(
150161
except Exception:
151162
pass
152163

164+
self._get_values_from_helper()
165+
153166
self.api_version = "4"
154167
try:
155168
self.api_version = self._config.get("global", "api_version")
@@ -192,3 +205,31 @@ def __init__(
192205
self.user_agent = self._config.get(self.gitlab_id, "user_agent")
193206
except Exception:
194207
pass
208+
209+
def _get_values_from_helper(self):
210+
"""Update attributes that may get values from an external helper program"""
211+
for attr in HELPER_ATTRIBUTES:
212+
value = getattr(self, attr)
213+
if not isinstance(value, str):
214+
continue
215+
216+
if not value.lower().strip().startswith(HELPER_PREFIX):
217+
continue
218+
219+
helper = value[len(HELPER_PREFIX) :].strip()
220+
commmand = [expanduser(expandvars(token)) for token in shlex.split(helper)]
221+
222+
try:
223+
value = (
224+
subprocess.check_output(commmand, stderr=subprocess.PIPE)
225+
.decode("utf-8")
226+
.strip()
227+
)
228+
except subprocess.CalledProcessError as e:
229+
stderr = e.stderr.decode().strip()
230+
raise GitlabConfigHelperError(
231+
f"Failed to read {attr} value from helper "
232+
f"for {self.gitlab_id}:\n{stderr}"
233+
) from e
234+
235+
setattr(self, attr, value)

gitlab/tests/test_config.py

+38
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import os
1919
import unittest
20+
from textwrap import dedent
2021

2122
import mock
2223
import io
@@ -193,6 +194,43 @@ def test_valid_data(m_open, path_exists):
193194
assert True == cp.ssl_verify
194195

195196

197+
@mock.patch("os.path.exists")
198+
@mock.patch("builtins.open")
199+
def test_data_from_helper(m_open, path_exists, tmp_path):
200+
helper = tmp_path / "helper.sh"
201+
helper.write_text(
202+
dedent(
203+
"""\
204+
#!/bin/sh
205+
echo "secret"
206+
"""
207+
)
208+
)
209+
helper.chmod(0o755)
210+
211+
fd = io.StringIO(
212+
dedent(
213+
"""\
214+
[global]
215+
default = helper
216+
217+
[helper]
218+
url = https://helper.url
219+
oauth_token = helper: %s
220+
"""
221+
)
222+
% helper
223+
)
224+
225+
fd.close = mock.Mock(return_value=None)
226+
m_open.return_value = fd
227+
cp = config.GitlabConfigParser(gitlab_id="helper")
228+
assert "helper" == cp.gitlab_id
229+
assert "https://helper.url" == cp.url
230+
assert None == cp.private_token
231+
assert "secret" == cp.oauth_token
232+
233+
196234
@mock.patch("os.path.exists")
197235
@mock.patch("builtins.open")
198236
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)