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
|
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.
|
||||||
|
@ -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"},
|
||||||
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
@ -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:
|
||||||
|
@ -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)],
|
||||||
)
|
)
|
||||||
|
Loading…
Reference in New Issue
Block a user