Support OIDC Client Credentials authentication to CTS
JIRA: RHELCMP-11324
Signed-off-by: Haibo Lin <hlin@redhat.com>
(cherry picked from commit e4c525ecbf
)
This commit is contained in:
parent
e2057b75c5
commit
cccfaea14e
@ -194,6 +194,17 @@ Options
|
||||
Tracking Service Kerberos authentication. If not defined, the default
|
||||
Kerberos principal is used.
|
||||
|
||||
**cts_oidc_token_url**
|
||||
(*str*) -- URL to the OIDC token endpoint.
|
||||
For example ``https://oidc.example.com/openid-connect/token``.
|
||||
This option can be overridden by the environment variable ``CTS_OIDC_TOKEN_URL``.
|
||||
|
||||
**cts_oidc_client_id*
|
||||
(*str*) -- OIDC client ID.
|
||||
This option can be overridden by the environment variable ``CTS_OIDC_CLIENT_ID``.
|
||||
Note that environment variable ``CTS_OIDC_CLIENT_SECRET`` must be configured with
|
||||
corresponding client secret to authenticate to CTS via OIDC.
|
||||
|
||||
**compose_type**
|
||||
(*str*) -- Allows to set default compose type. Type set via a command-line
|
||||
option overwrites this.
|
||||
|
@ -837,6 +837,8 @@ def make_schema():
|
||||
"pdc_insecure": {"deprecated": "Koji is queried instead"},
|
||||
"cts_url": {"type": "string"},
|
||||
"cts_keytab": {"type": "string"},
|
||||
"cts_oidc_token_url": {"type": "url"},
|
||||
"cts_oidc_client_id": {"type": "string"},
|
||||
"koji_profile": {"type": "string"},
|
||||
"koji_event": {"type": "number"},
|
||||
"pkgset_koji_tag": {"$ref": "#/definitions/strings"},
|
||||
|
@ -70,39 +70,82 @@ def is_status_fatal(status_code):
|
||||
|
||||
|
||||
@retry(wait_on=RequestException)
|
||||
def retry_request(method, url, data=None, auth=None):
|
||||
def retry_request(method, url, data=None, json_data=None, auth=None):
|
||||
"""
|
||||
:param str method: Reqest method.
|
||||
:param str url: Target URL.
|
||||
:param dict data: form-urlencoded data to send in the body of the request.
|
||||
:param dict json_data: json data to send in the body of the request.
|
||||
"""
|
||||
request_method = getattr(requests, method)
|
||||
rv = request_method(url, json=data, auth=auth)
|
||||
rv = request_method(url, data=data, json=json_data, auth=auth)
|
||||
if is_status_fatal(rv.status_code):
|
||||
try:
|
||||
error = rv.json()["message"]
|
||||
error = rv.json()
|
||||
except ValueError:
|
||||
error = rv.text
|
||||
raise RuntimeError("CTS responded with %d: %s" % (rv.status_code, error))
|
||||
raise RuntimeError("%s responded with %d: %s" % (url, rv.status_code, error))
|
||||
rv.raise_for_status()
|
||||
return rv
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def cts_auth(cts_keytab):
|
||||
auth = None
|
||||
if cts_keytab:
|
||||
# requests-kerberos cannot accept custom keytab, we need to use
|
||||
# environment variable for this. But we need to change environment
|
||||
# only temporarily just for this single requests.post.
|
||||
# So at first backup the current environment and revert to it
|
||||
# after the requests call.
|
||||
from requests_kerberos import HTTPKerberosAuth
|
||||
class BearerAuth(requests.auth.AuthBase):
|
||||
def __init__(self, token):
|
||||
self.token = token
|
||||
|
||||
auth = HTTPKerberosAuth()
|
||||
environ_copy = dict(os.environ)
|
||||
if "$HOSTNAME" in cts_keytab:
|
||||
cts_keytab = cts_keytab.replace("$HOSTNAME", socket.gethostname())
|
||||
os.environ["KRB5_CLIENT_KTNAME"] = cts_keytab
|
||||
os.environ["KRB5CCNAME"] = "DIR:%s" % tempfile.mkdtemp()
|
||||
def __call__(self, r):
|
||||
r.headers["authorization"] = "Bearer " + self.token
|
||||
return r
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def cts_auth(pungi_conf):
|
||||
"""
|
||||
:param dict pungi_conf: dict obj of pungi.json config.
|
||||
"""
|
||||
auth = None
|
||||
token = None
|
||||
cts_keytab = pungi_conf.get("cts_keytab")
|
||||
cts_oidc_token_url = os.environ.get("CTS_OIDC_TOKEN_URL", "") or pungi_conf.get(
|
||||
"cts_oidc_token_url"
|
||||
)
|
||||
|
||||
try:
|
||||
if cts_keytab:
|
||||
# requests-kerberos cannot accept custom keytab, we need to use
|
||||
# environment variable for this. But we need to change environment
|
||||
# only temporarily just for this single requests.post.
|
||||
# So at first backup the current environment and revert to it
|
||||
# after the requests call.
|
||||
from requests_kerberos import HTTPKerberosAuth
|
||||
|
||||
auth = HTTPKerberosAuth()
|
||||
environ_copy = dict(os.environ)
|
||||
if "$HOSTNAME" in cts_keytab:
|
||||
cts_keytab = cts_keytab.replace("$HOSTNAME", socket.gethostname())
|
||||
os.environ["KRB5_CLIENT_KTNAME"] = cts_keytab
|
||||
os.environ["KRB5CCNAME"] = "DIR:%s" % tempfile.mkdtemp()
|
||||
elif cts_oidc_token_url:
|
||||
cts_oidc_client_id = os.environ.get(
|
||||
"CTS_OIDC_CLIENT_ID", ""
|
||||
) or pungi_conf.get("cts_oidc_client_id", "")
|
||||
token = retry_request(
|
||||
"post",
|
||||
cts_oidc_token_url,
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": cts_oidc_client_id,
|
||||
"client_secret": os.environ.get("CTS_OIDC_CLIENT_SECRET", ""),
|
||||
},
|
||||
).json()["access_token"]
|
||||
auth = BearerAuth(token)
|
||||
del token
|
||||
|
||||
yield auth
|
||||
except Exception as e:
|
||||
# Avoid leaking client secret in trackback
|
||||
e.show_locals = False
|
||||
raise e
|
||||
finally:
|
||||
if cts_keytab:
|
||||
shutil.rmtree(os.environ["KRB5CCNAME"].split(":", 1)[1])
|
||||
@ -150,8 +193,8 @@ def get_compose_info(
|
||||
"parent_compose_ids": parent_compose_ids,
|
||||
"respin_of": respin_of,
|
||||
}
|
||||
with cts_auth(conf.get("cts_keytab")) as authentication:
|
||||
rv = retry_request("post", url, data=data, auth=authentication)
|
||||
with cts_auth(conf) as authentication:
|
||||
rv = retry_request("post", url, json_data=data, auth=authentication)
|
||||
|
||||
# Update local ComposeInfo with received ComposeInfo.
|
||||
cts_ci = ComposeInfo()
|
||||
@ -187,8 +230,8 @@ def update_compose_url(compose_id, compose_dir, conf):
|
||||
"action": "set_url",
|
||||
"compose_url": compose_url,
|
||||
}
|
||||
with cts_auth(conf.get("cts_keytab")) as authentication:
|
||||
return retry_request("patch", url, data=data, auth=authentication)
|
||||
with cts_auth(conf) as authentication:
|
||||
return retry_request("patch", url, json_data=data, auth=authentication)
|
||||
|
||||
|
||||
def get_compose_dir(
|
||||
@ -661,7 +704,7 @@ class Compose(kobo.log.LoggingBase):
|
||||
separators=(",", ": "),
|
||||
)
|
||||
|
||||
def traceback(self, detail=None):
|
||||
def traceback(self, detail=None, show_locals=True):
|
||||
"""Store an extended traceback. This method should only be called when
|
||||
handling an exception.
|
||||
|
||||
@ -673,7 +716,7 @@ class Compose(kobo.log.LoggingBase):
|
||||
tb_path = self.paths.log.log_file("global", basename)
|
||||
self.log_error("Extended traceback in: %s", tb_path)
|
||||
with open(tb_path, "wb") as f:
|
||||
f.write(kobo.tback.Traceback().get_traceback())
|
||||
f.write(kobo.tback.Traceback(show_locals=show_locals).get_traceback())
|
||||
|
||||
def load_old_compose_config(self):
|
||||
"""
|
||||
|
@ -690,7 +690,7 @@ def cli_main():
|
||||
except (Exception, KeyboardInterrupt) as ex:
|
||||
if COMPOSE:
|
||||
COMPOSE.log_error("Compose run failed: %s" % ex)
|
||||
COMPOSE.traceback()
|
||||
COMPOSE.traceback(show_locals=getattr(ex, "show_locals", True))
|
||||
COMPOSE.log_critical("Compose failed: %s" % COMPOSE.topdir)
|
||||
COMPOSE.write_status("DOOMED")
|
||||
else:
|
||||
|
@ -656,6 +656,7 @@ class ComposeTestCase(unittest.TestCase):
|
||||
mocked_requests.post.assert_called_once_with(
|
||||
"https://cts.localhost.tld/api/1/composes/",
|
||||
auth=mock.ANY,
|
||||
data=None,
|
||||
json=expected_json,
|
||||
)
|
||||
|
||||
@ -794,12 +795,16 @@ class TracebackTest(unittest.TestCase):
|
||||
shutil.rmtree(self.tmp_dir)
|
||||
self.patcher.stop()
|
||||
|
||||
def assertTraceback(self, filename):
|
||||
def assertTraceback(self, filename, show_locals=True):
|
||||
self.assertTrue(
|
||||
os.path.isfile("%s/logs/global/%s.global.log" % (self.tmp_dir, filename))
|
||||
)
|
||||
self.assertEqual(
|
||||
self.Traceback.mock_calls, [mock.call(), mock.call().get_traceback()]
|
||||
self.Traceback.mock_calls,
|
||||
[
|
||||
mock.call(show_locals=show_locals),
|
||||
mock.call(show_locals=show_locals).get_traceback(),
|
||||
],
|
||||
)
|
||||
|
||||
def test_traceback_default(self):
|
||||
@ -824,8 +829,8 @@ class RetryRequestTest(unittest.TestCase):
|
||||
self.assertEqual(
|
||||
mocked_requests.mock_calls,
|
||||
[
|
||||
mock.call.post(url, json=None, auth=None),
|
||||
mock.call.post(url, json=None, auth=None),
|
||||
mock.call.post(url, data=None, json=None, auth=None),
|
||||
mock.call.post(url, data=None, json=None, auth=None),
|
||||
],
|
||||
)
|
||||
self.assertEqual(rv.status_code, 200)
|
||||
@ -841,5 +846,5 @@ class RetryRequestTest(unittest.TestCase):
|
||||
|
||||
self.assertEqual(
|
||||
mocked_requests.mock_calls,
|
||||
[mock.call.post(url, json=None, auth=None)],
|
||||
[mock.call.post(url, data=None, json=None, auth=None)],
|
||||
)
|
||||
|
Loading…
Reference in New Issue
Block a user