Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[9.0] Include DiracX token in proxy PEM files #7261

Merged
merged 4 commits into from
Nov 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions integration_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,12 +146,13 @@ def create(
flags: Optional[list[str]] = typer.Argument(None),
editable: Optional[bool] = None,
extra_module: Optional[list[str]] = None,
diracx_dist_dir: Optional[str] = None,
release_var: Optional[str] = None,
run_server_tests: bool = True,
run_client_tests: bool = True,
):
"""Start a local instance of the integration tests"""
prepare_environment(flags, editable, extra_module, release_var)
prepare_environment(flags, editable, extra_module, diracx_dist_dir, release_var)
install_server()
install_client()
exit_code = 0
Expand Down Expand Up @@ -191,6 +192,7 @@ def prepare_environment(
flags: Optional[list[str]] = typer.Argument(None),
editable: Optional[bool] = None,
extra_module: Optional[list[str]] = None,
diracx_dist_dir: Optional[str] = None,
release_var: Optional[str] = None,
):
"""Prepare the local environment for installing DIRAC."""
Expand Down Expand Up @@ -227,7 +229,7 @@ def prepare_environment(
extra_services = list(chain(*[config["extra-services"] for config in module_configs.values()]))

typer.secho("Running docker-compose to create containers", fg=c.GREEN)
with _gen_docker_compose(modules) as docker_compose_fn:
with _gen_docker_compose(modules, diracx_dist_dir=diracx_dist_dir) as docker_compose_fn:
subprocess.run(
["docker-compose", "-f", docker_compose_fn, "up", "-d", "dirac-server", "dirac-client"] + extra_services,
check=True,
Expand Down Expand Up @@ -322,7 +324,7 @@ def prepare_environment(
typer.secho("Running docker-compose to create DiracX containers", fg=c.GREEN)
typer.secho(f"Will leave a folder behind: {docker_compose_fn_final}", fg=c.YELLOW)

with _gen_docker_compose(modules) as docker_compose_fn:
with _gen_docker_compose(modules, diracx_dist_dir=diracx_dist_dir) as docker_compose_fn:
# We cannot use the temporary directory created in the context manager because
# we don't stay in the contect manager (Popen)
# So we need something that outlives it.
Expand Down Expand Up @@ -545,7 +547,7 @@ class TestExit(typer.Exit):


@contextmanager
def _gen_docker_compose(modules):
def _gen_docker_compose(modules, *, diracx_dist_dir=None):
# Load the docker-compose configuration and mount the necessary volumes
input_fn = Path(__file__).parent / "tests/CI/docker-compose.yml"
docker_compose = yaml.safe_load(input_fn.read_text())
Expand All @@ -560,10 +562,12 @@ def _gen_docker_compose(modules):
docker_compose["services"]["diracx-wait-for-db"]["volumes"].extend(volumes[:])

module_configs = _load_module_configs(modules)
if "diracx" in module_configs:
docker_compose["services"]["diracx"]["volumes"].append(
f"{modules['diracx']}/src/diracx:{module_configs['diracx']['install-location']}"
)
if diracx_dist_dir is not None:
for container_name in ["dirac-client", "dirac-server", "diracx-init-cs", "diracx-wait-for-db", "diracx"]:
docker_compose["services"][container_name]["volumes"].append(f"{diracx_dist_dir}:/diracx_sources")
docker_compose["services"][container_name].setdefault("environment", []).append(
"DIRACX_CUSTOM_SOURCE_PREFIXES=/diracx_sources"
)

# Add any extension services
for module_name, module_configs in module_configs.items():
Expand Down
3 changes: 3 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ install_requires =
cachetools
certifi
diraccfg
diracx-client
diracx-core
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in dirac_ci.sh you also added diracx-cli

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is for normal installation. In dirac_ci.sh, we install what's in the main branch

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's only needed for the integration test setup itself

db12
fts3
gfal2-python
Expand Down Expand Up @@ -160,6 +162,7 @@ console_scripts =
# FrameworkSystem
dirac-login = DIRAC.FrameworkSystem.scripts.dirac_login:main
dirac-logout = DIRAC.FrameworkSystem.scripts.dirac_logout:main
dirac-diracx-whoami = DIRAC.FrameworkSystem.scripts.dirac_diracx_whoami:main
dirac-admin-get-CAs = DIRAC.FrameworkSystem.scripts.dirac_admin_get_CAs:main [server]
dirac-admin-get-proxy = DIRAC.FrameworkSystem.scripts.dirac_admin_get_proxy:main [admin]
dirac-admin-proxy-upload = DIRAC.FrameworkSystem.scripts.dirac_admin_proxy_upload:main [admin]
Expand Down
85 changes: 85 additions & 0 deletions src/DIRAC/Core/Security/DiracX.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
from __future__ import annotations
fstagni marked this conversation as resolved.
Show resolved Hide resolved

__all__ = (
"DiracXClient",
"diracxTokenFromPEM",
)

import base64
import json
import re
import textwrap
from contextlib import contextmanager
from pathlib import Path
from tempfile import NamedTemporaryFile
from typing import Any

from diracx.client import DiracClient as _DiracClient
from diracx.core.models import TokenResponse
from diracx.core.preferences import DiracxPreferences
from diracx.core.utils import serialize_credentials

from DIRAC import gConfig, S_ERROR
from DIRAC.ConfigurationSystem.Client.Helpers import Registry
from DIRAC.Core.Security.Locations import getDefaultProxyLocation
from DIRAC.Core.Utilities.ReturnValues import convertToReturnValue, returnValueOrRaise


PEM_BEGIN = "-----BEGIN DIRACX-----"
PEM_END = "-----END DIRACX-----"
RE_DIRACX_PEM = re.compile(rf"{PEM_BEGIN}\n(.*)\n{PEM_END}", re.MULTILINE | re.DOTALL)


@convertToReturnValue
def addTokenToPEM(pemPath, group):
from DIRAC.Core.Base.Client import Client

vo = Registry.getVOMSVOForGroup(group)
disabledVOs = gConfig.getValue("/DiracX/DisabledVOs", [])
if vo and vo not in disabledVOs:
token_content = returnValueOrRaise(
Client(url="Framework/ProxyManager", proxyLocation=pemPath).exchangeProxyForToken()
)

token = TokenResponse(
access_token=token_content["access_token"],
expires_in=token_content["expires_in"],
token_type=token_content.get("token_type"),
refresh_token=token_content.get("refresh_token"),
)

token_pem = f"{PEM_BEGIN}\n"
data = base64.b64encode(serialize_credentials(token).encode("utf-8")).decode()
token_pem += textwrap.fill(data, width=64)
token_pem += f"\n{PEM_END}\n"

with open(pemPath, "a") as f:
f.write(token_pem)


def diracxTokenFromPEM(pemPath) -> dict[str, Any] | None:
"""Extract the DiracX token from the proxy PEM file"""
pem = Path(pemPath).read_text()
if match := RE_DIRACX_PEM.search(pem):
match = match.group(1)
return json.loads(base64.b64decode(match).decode("utf-8"))


@contextmanager
def DiracXClient() -> _DiracClient:
"""Get a DiracX client instance with the current user's credentials"""
diracxUrl = gConfig.getValue("/DiracX/URL")
if not diracxUrl:
raise ValueError("Missing mandatory /DiracX/URL configuration")

proxyLocation = getDefaultProxyLocation()
diracxToken = diracxTokenFromPEM(proxyLocation)

with NamedTemporaryFile(mode="wt") as token_file:
token_file.write(json.dumps(diracxToken))
token_file.flush()
token_file.seek(0)

pref = DiracxPreferences(url=diracxUrl, credentials_path=token_file.name)
with _DiracClient(diracx_preferences=pref) as api:
yield api
8 changes: 8 additions & 0 deletions src/DIRAC/Core/Security/ProxyInfo.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from DIRAC.Core.Security.X509Chain import X509Chain # pylint: disable=import-error
from DIRAC.Core.Security.VOMS import VOMS
from DIRAC.Core.Security import Locations
from DIRAC.Core.Security.DiracX import diracxTokenFromPEM

from DIRAC.ConfigurationSystem.Client.Helpers import Registry

Expand All @@ -25,6 +26,7 @@ def getProxyInfo(proxy=False, disableVOMS=False):
* 'validDN' : Valid DN in DIRAC
* 'validGroup' : Valid Group in DIRAC
* 'secondsLeft' : Seconds left
* 'hasDiracxToken'
* values that can be there
* 'path' : path to the file,
* 'group' : DIRAC group
Expand Down Expand Up @@ -67,6 +69,11 @@ def getProxyInfo(proxy=False, disableVOMS=False):
infoDict["VOMS"] = retVal["Value"]
else:
infoDict["VOMSError"] = retVal["Message"].strip()

infoDict["hasDiracxToken"] = False
if proxyLocation:
infoDict["hasDiracxToken"] = bool(diracxTokenFromPEM(proxyLocation))

return S_OK(infoDict)


Expand Down Expand Up @@ -94,6 +101,7 @@ def formatProxyInfoAsString(infoDict):
"subproxyUser",
("secondsLeft", "timeleft"),
("group", "DIRAC group"),
("hasDiracxToken", "DiracX"),
"rfc",
"path",
"username",
Expand Down
8 changes: 5 additions & 3 deletions src/DIRAC/Core/Tornado/Client/private/TornadoBaseClient.py
Original file line number Diff line number Diff line change
Expand Up @@ -511,10 +511,12 @@ def _request(self, retry=0, outputFile=None, **kwargs):
# getting certificate
# Do we use the server certificate ?
if self.kwargs[self.KW_USE_CERTIFICATES]:
# TODO: make this code path work with DiracX for Agents and possibly webapp ?
auth = {"cert": Locations.getHostCertificateAndKeyLocation()}

# Use access token?
elif self.__useAccessToken:
# TODO: Remove this code path?
from DIRAC.FrameworkSystem.private.authorization.utils.Tokens import (
getLocalTokenDict,
writeTokenDictToTokenFile,
Expand Down Expand Up @@ -543,13 +545,13 @@ def _request(self, retry=0, outputFile=None, **kwargs):

auth = {"headers": {"Authorization": f"Bearer {token['access_token']}"}}
elif self.kwargs.get(self.KW_PROXY_STRING):
# TODO: This code path cannot work with DiracX
tmpHandle, cert = tempfile.mkstemp()
fp = os.fdopen(tmpHandle, "w")
fp.write(self.kwargs[self.KW_PROXY_STRING])
fp.close()

# CHRIS 04.02.21
# TODO: add proxyLocation check ?
elif self.kwargs.get(self.KW_PROXY_LOCATION):
auth = {"cert": self.kwargs[self.KW_PROXY_LOCATION]}
else:
auth = {"cert": Locations.getProxyLocation()}
if not auth["cert"]:
Expand Down
14 changes: 13 additions & 1 deletion src/DIRAC/FrameworkSystem/Client/ProxyManagerClient.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from DIRAC.ConfigurationSystem.Client.Helpers import Registry
from DIRAC.Core.Utilities import ThreadSafe, DIRACSingleton
from DIRAC.Core.Utilities.DictCache import DictCache
from DIRAC.Core.Security.DiracX import addTokenToPEM
from DIRAC.Core.Security.ProxyFile import multiProxyArgument, deleteMultiProxy
from DIRAC.Core.Security.X509Chain import X509Chain # pylint: disable=import-error
from DIRAC.Core.Security.X509Request import X509Request # pylint: disable=import-error
Expand Down Expand Up @@ -547,6 +548,10 @@ def dumpProxyToFile(self, chain, destinationFile=None, requiredTimeLeft=600):
if not retVal["OK"]:
return retVal
filename = retVal["Value"]
if not (result := chain.getDIRACGroup())["OK"]:
return result
if not (result := addTokenToPEM(filename, result["Value"]))["OK"]: # pylint: disable=unsubscriptable-object
return result
self.__filesCache.add(cHash, chain.getRemainingSecs()["Value"], filename)
return S_OK(filename)

Expand Down Expand Up @@ -655,7 +660,14 @@ def renewProxy(self, proxyToBeRenewed=None, minLifeTime=3600, newProxyLifeTime=4
chain = retVal["Value"]

if not proxyToRenewDict["tempFile"]:
return chain.dumpAllToFile(proxyToRenewDict["file"])
filename = proxyToRenewDict["file"]
if not (result := chain.dumpAllToFile(filename))["OK"]:
return result
if not (result := chain.getDIRACGroup())["OK"]:
return result
if not (result := addTokenToPEM(filename, result["Value"]))["OK"]: # pylint: disable=unsubscriptable-object
return result
return S_OK(filename)

return S_OK(chain)

Expand Down
3 changes: 1 addition & 2 deletions src/DIRAC/FrameworkSystem/Service/ProxyManagerHandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from DIRAC.Core.Security import Properties
from DIRAC.Core.Utilities.ObjectLoader import ObjectLoader
from DIRAC.ConfigurationSystem.Client.Helpers import Registry

from DIRAC.FrameworkSystem.Utilities.diracx import get_token

DEFAULT_MAIL_FROM = "[email protected]"

Expand Down Expand Up @@ -412,7 +412,6 @@ def export_getVOMSProxyWithToken(self, userDN, userGroup, requestPem, requiredLi
@convertToReturnValue
def export_exchangeProxyForToken(self):
"""Exchange a proxy for an equivalent token to be used with diracx"""
from DIRAC.FrameworkSystem.Utilities.diracx import get_token

credDict = self.getRemoteCredentials()
return get_token(
Expand Down
1 change: 0 additions & 1 deletion src/DIRAC/FrameworkSystem/Utilities/diracx.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
# pylint: disable=import-error
import requests

from cachetools import TTLCache, cached
Expand Down
5 changes: 5 additions & 0 deletions src/DIRAC/FrameworkSystem/scripts/dirac_admin_get_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import DIRAC
from DIRAC import gLogger, S_OK, S_ERROR
from DIRAC.Core.Base.Script import Script
from DIRAC.Core.Security.DiracX import addTokenToPEM
from DIRAC.FrameworkSystem.Client.ProxyManagerClient import gProxyManager
from DIRAC.ConfigurationSystem.Client.Helpers import Registry

Expand Down Expand Up @@ -159,6 +160,10 @@ def main():
if not result["OK"]:
gLogger.notice(f"Proxy file cannot be written to {params.proxyPath}: {result['Message']}")
DIRAC.exit(2)
if not (result := chain.getDIRACGroup())["OK"]:
return result
if not (result := addTokenToPEM(params.proxyPath, result["Value"]))["OK"]: # pylint: disable=unsubscriptable-object
return result
gLogger.notice(f"Proxy downloaded to {params.proxyPath}")
DIRAC.exit(0)

Expand Down
22 changes: 22 additions & 0 deletions src/DIRAC/FrameworkSystem/scripts/dirac_diracx_whoami.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""Query DiracX for information about the current user

This is a stripped down version of the "dirac whoami" script from DiracX.
It primarily exists as a method of validating the current user's credentials are functional.
"""
import json

from DIRAC.Core.Base.Script import Script
from DIRAC.Core.Security.DiracX import DiracXClient


@Script()
def main():
Script.parseCommandLine()

with DiracXClient() as api:
user_info = api.auth.userinfo()
print(json.dumps(user_info.as_dict(), indent=2))


if __name__ == "__main__":
main()
29 changes: 3 additions & 26 deletions src/DIRAC/FrameworkSystem/scripts/dirac_login.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from DIRAC import gConfig, gLogger, S_OK, S_ERROR
from DIRAC.Core.Security.Locations import getDefaultProxyLocation, getCertificateAndKeyLocation
from DIRAC.Core.Security.VOMS import VOMS
from DIRAC.Core.Security.DiracX import addTokenToPEM
from DIRAC.Core.Security.ProxyFile import writeToProxyFile
from DIRAC.Core.Security.ProxyInfo import getProxyInfo, formatProxyInfoAsString
from DIRAC.Core.Security.X509Chain import X509Chain # pylint: disable=import-error
Expand Down Expand Up @@ -314,32 +315,8 @@ def loginWithCertificate(self):
return res

# Get a token for use with diracx
vo = getVOMSVOForGroup(self.group)
disabledVOs = gConfig.getValue("/DiracX/DisabledVOs", [])
if vo not in disabledVOs:
from diracx.core.utils import write_credentials # pylint: disable=import-error
from diracx.core.models import TokenResponse # pylint: disable=import-error
from diracx.core.preferences import DiracxPreferences # pylint: disable=import-error

res = Client(url="Framework/ProxyManager").exchangeProxyForToken()
if not res["OK"]:
return res
token_content = res["Value"]

diracxUrl = gConfig.getValue("/DiracX/URL")
if not diracxUrl:
return S_ERROR("Missing mandatory /DiracX/URL configuration")

preferences = DiracxPreferences(url=diracxUrl)
write_credentials(
TokenResponse(
access_token=token_content["access_token"],
expires_in=token_content["expires_in"],
token_type=token_content.get("token_type"),
refresh_token=token_content.get("refresh_token"),
),
location=preferences.credentials_path,
)
if not (result := addTokenToPEM(self.outputFile, self.group))["OK"]:
return result

return S_OK()

Expand Down
7 changes: 7 additions & 0 deletions src/DIRAC/FrameworkSystem/scripts/dirac_proxy_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ def main():
from DIRAC.Core.Security import VOMS
from DIRAC.FrameworkSystem.Client.ProxyManagerClient import gProxyManager
from DIRAC.ConfigurationSystem.Client.Helpers import Registry
from DIRAC.Core.Security.DiracX import DiracXClient

if params.csEnabled:
retVal = Script.enableCS()
Expand Down Expand Up @@ -151,6 +152,12 @@ def invalidProxy(msg):
invalidProxy(f"Cannot determine life time of VOMS attributes: {result['Message']}")
if int(result["Value"].strip()) == 0:
invalidProxy("VOMS attributes are expired")
# Ensure the proxy is working with DiracX
try:
with DiracXClient() as api:
api.auth.userinfo()
except Exception as e:
invalidProxy(f"Failed to access DiracX: {e}")
Comment on lines +155 to +160
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It this absolutely needed at this stage?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If what is absolutely need ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes


sys.exit(0)

Expand Down
Loading
Loading