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:
Haibo Lin 2023-06-26 13:13:08 +08:00 committed by Stepan Oksanichenko
parent e2057b75c5
commit cccfaea14e
Signed by: soksanichenko
GPG Key ID: AB9983172AB1E45B
5 changed files with 93 additions and 32 deletions

View File

@ -194,6 +194,17 @@ Options
Tracking Service Kerberos authentication. If not defined, the default Tracking Service Kerberos authentication. If not defined, the default
Kerberos principal is used. 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** **compose_type**
(*str*) -- Allows to set default compose type. Type set via a command-line (*str*) -- Allows to set default compose type. Type set via a command-line
option overwrites this. option overwrites this.

View File

@ -837,6 +837,8 @@ def make_schema():
"pdc_insecure": {"deprecated": "Koji is queried instead"}, "pdc_insecure": {"deprecated": "Koji is queried instead"},
"cts_url": {"type": "string"}, "cts_url": {"type": "string"},
"cts_keytab": {"type": "string"}, "cts_keytab": {"type": "string"},
"cts_oidc_token_url": {"type": "url"},
"cts_oidc_client_id": {"type": "string"},
"koji_profile": {"type": "string"}, "koji_profile": {"type": "string"},
"koji_event": {"type": "number"}, "koji_event": {"type": "number"},
"pkgset_koji_tag": {"$ref": "#/definitions/strings"}, "pkgset_koji_tag": {"$ref": "#/definitions/strings"},

View File

@ -70,39 +70,82 @@ def is_status_fatal(status_code):
@retry(wait_on=RequestException) @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) 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): if is_status_fatal(rv.status_code):
try: try:
error = rv.json()["message"] error = rv.json()
except ValueError: except ValueError:
error = rv.text 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() rv.raise_for_status()
return rv return rv
@contextlib.contextmanager class BearerAuth(requests.auth.AuthBase):
def cts_auth(cts_keytab): def __init__(self, token):
auth = None self.token = token
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() def __call__(self, r):
environ_copy = dict(os.environ) r.headers["authorization"] = "Bearer " + self.token
if "$HOSTNAME" in cts_keytab: return r
cts_keytab = cts_keytab.replace("$HOSTNAME", socket.gethostname())
os.environ["KRB5_CLIENT_KTNAME"] = cts_keytab
os.environ["KRB5CCNAME"] = "DIR:%s" % tempfile.mkdtemp() @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: 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 yield auth
except Exception as e:
# Avoid leaking client secret in trackback
e.show_locals = False
raise e
finally: finally:
if cts_keytab: if cts_keytab:
shutil.rmtree(os.environ["KRB5CCNAME"].split(":", 1)[1]) shutil.rmtree(os.environ["KRB5CCNAME"].split(":", 1)[1])
@ -150,8 +193,8 @@ def get_compose_info(
"parent_compose_ids": parent_compose_ids, "parent_compose_ids": parent_compose_ids,
"respin_of": respin_of, "respin_of": respin_of,
} }
with cts_auth(conf.get("cts_keytab")) as authentication: with cts_auth(conf) as authentication:
rv = retry_request("post", url, data=data, auth=authentication) rv = retry_request("post", url, json_data=data, auth=authentication)
# Update local ComposeInfo with received ComposeInfo. # Update local ComposeInfo with received ComposeInfo.
cts_ci = ComposeInfo() cts_ci = ComposeInfo()
@ -187,8 +230,8 @@ def update_compose_url(compose_id, compose_dir, conf):
"action": "set_url", "action": "set_url",
"compose_url": compose_url, "compose_url": compose_url,
} }
with cts_auth(conf.get("cts_keytab")) as authentication: with cts_auth(conf) as authentication:
return retry_request("patch", url, data=data, auth=authentication) return retry_request("patch", url, json_data=data, auth=authentication)
def get_compose_dir( def get_compose_dir(
@ -661,7 +704,7 @@ class Compose(kobo.log.LoggingBase):
separators=(",", ": "), 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 """Store an extended traceback. This method should only be called when
handling an exception. handling an exception.
@ -673,7 +716,7 @@ class Compose(kobo.log.LoggingBase):
tb_path = self.paths.log.log_file("global", basename) tb_path = self.paths.log.log_file("global", basename)
self.log_error("Extended traceback in: %s", tb_path) self.log_error("Extended traceback in: %s", tb_path)
with open(tb_path, "wb") as f: 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): def load_old_compose_config(self):
""" """

View File

@ -690,7 +690,7 @@ def cli_main():
except (Exception, KeyboardInterrupt) as ex: except (Exception, KeyboardInterrupt) as ex:
if COMPOSE: if COMPOSE:
COMPOSE.log_error("Compose run failed: %s" % ex) 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.log_critical("Compose failed: %s" % COMPOSE.topdir)
COMPOSE.write_status("DOOMED") COMPOSE.write_status("DOOMED")
else: else:

View File

@ -656,6 +656,7 @@ class ComposeTestCase(unittest.TestCase):
mocked_requests.post.assert_called_once_with( mocked_requests.post.assert_called_once_with(
"https://cts.localhost.tld/api/1/composes/", "https://cts.localhost.tld/api/1/composes/",
auth=mock.ANY, auth=mock.ANY,
data=None,
json=expected_json, json=expected_json,
) )
@ -794,12 +795,16 @@ class TracebackTest(unittest.TestCase):
shutil.rmtree(self.tmp_dir) shutil.rmtree(self.tmp_dir)
self.patcher.stop() self.patcher.stop()
def assertTraceback(self, filename): def assertTraceback(self, filename, show_locals=True):
self.assertTrue( self.assertTrue(
os.path.isfile("%s/logs/global/%s.global.log" % (self.tmp_dir, filename)) os.path.isfile("%s/logs/global/%s.global.log" % (self.tmp_dir, filename))
) )
self.assertEqual( 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): def test_traceback_default(self):
@ -824,8 +829,8 @@ class RetryRequestTest(unittest.TestCase):
self.assertEqual( self.assertEqual(
mocked_requests.mock_calls, mocked_requests.mock_calls,
[ [
mock.call.post(url, json=None, auth=None), mock.call.post(url, data=None, json=None, auth=None),
mock.call.post(url, json=None, auth=None), mock.call.post(url, data=None, json=None, auth=None),
], ],
) )
self.assertEqual(rv.status_code, 200) self.assertEqual(rv.status_code, 200)
@ -841,5 +846,5 @@ class RetryRequestTest(unittest.TestCase):
self.assertEqual( self.assertEqual(
mocked_requests.mock_calls, mocked_requests.mock_calls,
[mock.call.post(url, json=None, auth=None)], [mock.call.post(url, data=None, json=None, auth=None)],
) )