From 99ccd9bfed764ce2b674806937b45e5c70b385a4 Mon Sep 17 00:00:00 2001 From: Chris Burr Date: Wed, 7 Oct 2026 06:28:52 +0200 Subject: [PATCH 1/2] feat: add dirac-admin-debug-user and improve dirac-proxy-init registration errors dirac-admin-debug-user looks up a user by certificate DN, username, CERN account, email or CERN person ID and reports common registry problems (CA mismatch, no groups, suspension, expiring affiliation and, with the ProxyManagement property, uploaded proxies). If there is no exact match similar users are shown, matching on CERN person ID/account and name similarity. As an unregistered user cannot query the CS, it is intended to be run by a colleague on behalf of the user. dirac-proxy-init now prints the certificate subject and issuer together with the corresponding dirac-admin-debug-user command when the DN is not registered or VOMS refuses to issue an AC. In --strict mode the VOMS error was also printed twice, it is now only printed once. (cherry picked from commit 4624b1162224552750dfe0e16941df6d68d32e16) --- setup.cfg | 1 + .../scripts/dirac_admin_debug_user.py | 274 ++++++++++++++++++ .../scripts/dirac_proxy_init.py | 42 ++- 3 files changed, 312 insertions(+), 5 deletions(-) create mode 100644 src/DIRAC/FrameworkSystem/scripts/dirac_admin_debug_user.py diff --git a/setup.cfg b/setup.cfg index b5eddc216bd..26cf4926c7a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -181,6 +181,7 @@ console_scripts = dirac-admin-sysadmin-cli = DIRAC.FrameworkSystem.scripts.dirac_admin_sysadmin_cli:main [admin] dirac-admin-update-instance = DIRAC.FrameworkSystem.scripts.dirac_admin_update_instance:main [admin] dirac-admin-update-pilot = DIRAC.FrameworkSystem.scripts.dirac_admin_update_pilot:main [admin] + dirac-admin-debug-user = DIRAC.FrameworkSystem.scripts.dirac_admin_debug_user:main dirac-admin-users-with-proxy = DIRAC.FrameworkSystem.scripts.dirac_admin_users_with_proxy:main [admin] dirac-install-component = DIRAC.FrameworkSystem.scripts.dirac_install_component:main [server] dirac-proxy-destroy = DIRAC.FrameworkSystem.scripts.dirac_proxy_destroy:main diff --git a/src/DIRAC/FrameworkSystem/scripts/dirac_admin_debug_user.py b/src/DIRAC/FrameworkSystem/scripts/dirac_admin_debug_user.py new file mode 100644 index 00000000000..7a3291feac8 --- /dev/null +++ b/src/DIRAC/FrameworkSystem/scripts/dirac_admin_debug_user.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python +"""Help to understand why a user is unable to get a proxy. + +The user can be given as a certificate DN (as printed by dirac-proxy-init or +"openssl x509 -subject"), a DIRAC username, a CERN account, an email address or +a CERN person ID. If there is no exact match, similar users from the registry +are shown instead. + +As an unregistered user cannot query the configuration, this command is +intended to be run by a colleague on behalf of the user having problems. + +Example: + $ dirac-admin-debug-user '/DC=ch/DC=cern/OU=Organic Units/OU=Users/CN=jdoe/CN=123456/CN=Jane Doe' + $ dirac-admin-debug-user --ca '/DC=ch/DC=cern/CN=CERN Grid Certification Authority' jdoe +""" + +import re +from datetime import date, datetime, timedelta, timezone +from difflib import SequenceMatcher + +from DIRAC import gConfig, exit as dexit +from DIRAC.Core.Base.Script import Script + +MIN_SCORE = 0.75 +# Scores at or above this mean the user is very likely the same person +STRONG_SCORE = 0.95 +# Proxy CNs are appended as large random integers, much longer than CERN person IDs +PROXY_CN_RE = re.compile(r"/CN=(\d{8,}|proxy|limited proxy)$") + + +def normaliseDN(dn): + """Convert the various ways a DN can be written to the slash separated form used by DIRAC""" + dn = dn.strip() + if dn.startswith(("subject=", "issuer=")): + dn = dn.split("=", 1)[1].strip() + if not dn.startswith("/"): + # OpenSSL 3 style: "DC = ch, DC = cern, OU = Organic Units, CN = Jane Doe" + dn = "/" + "/".join(re.sub(r"\s*=\s*", "=", part.strip()) for part in dn.split(",")) + stripped = False + while match := PROXY_CN_RE.search(dn): + dn = dn[: match.start()] + stripped = True + return dn, stripped + + +def splitDN(dn): + """Split a slash separated DN into a list of (key, value) tuples""" + return [tuple(part.split("=", 1)) for part in dn.strip("/").split("/") if "=" in part] + + +def nameFromDN(dn): + """Get the last non numeric CN which, for most CAs, is the person's name""" + names = [value for key, value in splitDN(dn) if key == "CN" and not value.isdigit()] + return names[-1].lower() if names else "" + + +def loadUsers(): + users = {} + result = gConfig.getSections("/Registry/Users") + if not result["OK"]: + raise RuntimeError(result["Message"]) + for username in result["Value"]: + path = f"/Registry/Users/{username}" + users[username] = { + "DN": gConfig.getValue(f"{path}/DN", []), + "CA": gConfig.getValue(f"{path}/CA", []), + "Email": gConfig.getValue(f"{path}/Email", ""), + # "None" is used as a placeholder by the synchronisation + "Suspended": [vo for vo in gConfig.getValue(f"{path}/Suspended", []) if vo != "None"], + "PrimaryCERNAccount": gConfig.getValue(f"{path}/PrimaryCERNAccount", ""), + "CERNAccountType": gConfig.getValue(f"{path}/CERNAccountType", ""), + "CERNPersonId": gConfig.getValue(f"{path}/CERNPersonId", ""), + "AffiliationEnds": gConfig.getOptionsDict(f"{path}/AffiliationEnds").get("Value", {}), + } + return users + + +def scoreDN(queryDN, info): + """Return (score, reason) for how likely it is that a user corresponds to the given DN""" + queryCNs = {value.lower() for key, value in splitDN(queryDN) if key == "CN"} + queryName = nameFromDN(queryDN) + best = (0.0, "") + for dn in info["DN"]: + if dn == queryDN: + return 1.0, "identical DN" + # Only compare the names as the rest of the DN is often identical for everyone with the same CA + best = max(best, (SequenceMatcher(None, queryName, nameFromDN(dn)).ratio(), "similar name")) + # A matching CERN person ID or account name is a much stronger signal than the name + if info["CERNPersonId"] and info["CERNPersonId"] in queryCNs: + best = max(best, (0.98, f"same CERN person ID ({info['CERNPersonId']})")) + if info["PrimaryCERNAccount"] and info["PrimaryCERNAccount"].lower() in queryCNs: + best = max(best, (0.95, f"same CERN account ({info['PrimaryCERNAccount']})")) + return best + + +def findUsers(query, users, limit): + """Find the users matching a query, returns (exact, similar, normalisedDN)""" + if query.startswith("/") or "=" in query: + dn, stripped = normaliseDN(query) + if stripped: + print(f"NOTE: Removed proxy CN(s) from the DN, using {dn}\n") + scored = sorted( + ((*scoreDN(dn, info), username) for username, info in users.items()), + reverse=True, + ) + exact = [username for score, _, username in scored if score == 1.0] + similar = [(username, score, reason) for score, reason, username in scored if MIN_SCORE <= score < 1.0] + return exact, similar[:limit], dn + + q = query.strip().lower() + exact = [ + username + for username, info in users.items() + if q in (username.lower(), info["Email"].lower(), info["PrimaryCERNAccount"].lower(), info["CERNPersonId"]) + ] + similar = [] + if not exact: + scored = sorted( + ( + ( + max(SequenceMatcher(None, q, s.lower()).ratio() for s in (username, info["Email"].split("@")[0])), + username, + ) + for username, info in users.items() + ), + reverse=True, + ) + similar = [(username, score, "similar username/email") for score, username in scored if score >= MIN_SCORE] + return exact, similar[:limit], None + + +def canReadProxyDB(): + from DIRAC.Core.Security import Properties + from DIRAC.Core.Security.ProxyInfo import getProxyInfo + + result = getProxyInfo() + if not result["OK"]: + return False + return Properties.PROXY_MANAGEMENT in result["Value"].get("groupProperties", []) + + +def getUploadedProxies(username): + from DIRAC.FrameworkSystem.Client.ProxyManagerClient import gProxyManager + + result = gProxyManager.getDBContents({"UserName": [username]}) + if not result["OK"]: + return result + names = result["Value"]["ParameterNames"] + return {"OK": True, "Value": [dict(zip(names, record)) for record in result["Value"]["Records"]]} + + +def checkUser(username, info, queryDN, queryCA, checkProxies): + """Print the details of a user and return a list of problems found""" + from DIRAC.ConfigurationSystem.Client.Helpers import Registry + + problems = [] + groups = Registry.getGroupsForUser(username).get("Value", []) + + print(f"* {username}") + for dn in info["DN"]: + print(f" DN : {dn}{' <-- matches' if dn == queryDN else ''}") + for ca in info["CA"]: + print(f" CA : {ca}") + print(f" Email : {info['Email'] or '-'}") + if info["PrimaryCERNAccount"]: + print(f" CERN account : {info['PrimaryCERNAccount']} ({info['CERNAccountType'] or 'unknown type'})") + if info["CERNPersonId"]: + print(f" CERN person ID: {info['CERNPersonId']}") + print(f" Groups : {', '.join(groups) or '-'}") + + if queryCA and queryCA not in info["CA"]: + problems.append(f"the given CA is not registered for this user (registered: {', '.join(info['CA']) or '-'})") + if not groups: + problems.append("the user is not a member of any group") + if info["Suspended"]: + problems.append(f"the user is suspended in: {', '.join(info['Suspended'])}") + + today = date.today() + for vo, endDate in info["AffiliationEnds"].items(): + print(f" Affiliation : {vo} until {endDate}") + try: + endDate = date.fromisoformat(endDate) + except ValueError: + continue + if endDate < today: + problems.append(f"the {vo} affiliation ended on {endDate}") + elif endDate < today + timedelta(days=30): + problems.append(f"the {vo} affiliation ends soon ({endDate})") + + if checkProxies: + result = getUploadedProxies(username) + if not result["OK"]: + print(f" Proxies : failed to query ProxyManager: {result['Message']}") + elif not result["Value"]: + print(" Proxies : none uploaded") + problems.append("no proxy has been uploaded to the ProxyManager") + else: + now = datetime.now(timezone.utc).replace(tzinfo=None) + validProxies = [] + for proxy in result["Value"]: + expiry = proxy["ExpirationTime"] + state = "valid" if expiry > now else "EXPIRED" + print(f" Proxy : {proxy['UserDN']} until {expiry:%Y-%m-%d %H:%M} ({state})") + if expiry > now and proxy["UserDN"] in info["DN"]: + validProxies.append(proxy) + if not validProxies: + problems.append("there is no valid uploaded proxy for any of the registered DNs") + + for problem in problems: + print(f" PROBLEM : {problem}") + if not problems: + print(" No problems found in the DIRAC registry") + print() + return problems + + +@Script() +def main(): + Script.registerSwitch("", "ca=", "CA (issuer DN) of the user's certificate") + Script.registerSwitch("n:", "limit=", "Maximum number of similar users to show (default 5)") + Script.registerArgument( + "user: certificate DN, DIRAC username, CERN account, email or CERN person ID", mandatory=True + ) + Script.parseCommandLine(ignoreErrors=False) + + queryCA = None + limit = 5 + for switch, value in Script.getUnprocessedSwitches(): + if switch == "ca": + queryCA = normaliseDN(value)[0] + elif switch in ("n", "limit"): + limit = int(value) + query = " ".join(Script.getPositionalArgs()) + + users = loadUsers() + exact, similar, queryDN = findUsers(query, users, limit) + + checkProxies = canReadProxyDB() + if not checkProxies: + print("NOTE: Uploaded proxies will not be checked as this requires the ProxyManagement property\n") + + problems = [] + if exact: + print(f"Found {len(exact)} user(s) matching {query!r}:\n") + for username in exact: + problems += checkUser(username, users[username], queryDN, queryCA, checkProxies) + else: + problems.append("no exact match") + if similar: + print(f"No user exactly matches {query!r}, the most similar users are:\n") + for username, score, reason in similar: + print(f" (score {score:.2f}: {reason})") + if queryDN and score >= STRONG_SCORE: + print(" This is very likely the same person: the new certificate must be registered with the VO") + checkUser(username, users[username], queryDN, queryCA, checkProxies) + else: + print(f"No user exactly matches {query!r} and no similar users were found.\n") + + if problems or not exact: + print( + "If the DN has changed (e.g. renewed certificate, name change or a new CA) the new\n" + "certificate must be registered with the VO (e.g. linked to the user's account in IAM).\n" + "Changes in the VO membership service can take a few hours to propagate to DIRAC." + ) + dexit(1) + print( + "The user looks fine in the DIRAC registry. If VOMS still refuses to give an attribute certificate,\n" + "check the certificate subject and issuer registered with the VO (e.g. in IAM)." + ) + dexit(0) + + +if __name__ == "__main__": + main() diff --git a/src/DIRAC/FrameworkSystem/scripts/dirac_proxy_init.py b/src/DIRAC/FrameworkSystem/scripts/dirac_proxy_init.py index 5da65c97e8d..6887dcaa7f4 100755 --- a/src/DIRAC/FrameworkSystem/scripts/dirac_proxy_init.py +++ b/src/DIRAC/FrameworkSystem/scripts/dirac_proxy_init.py @@ -9,6 +9,7 @@ import datetime import glob import os +import shlex import sys import time @@ -50,6 +51,7 @@ def __init__(self, piParams): self.__issuerCert = False self.__proxyGenerated = False self.__uploadedInfo = {} + self.debugHintNeeded = False def getIssuerCert(self): if self.__issuerCert: @@ -117,6 +119,8 @@ def createProxy(self): resultProxyGenerated = ProxyGeneration.generateProxy(piParams) if not resultProxyGenerated["OK"]: gLogger.error(resultProxyGenerated["Message"]) + if "is not registered" in resultProxyGenerated["Message"]: + self.printDebugHint() sys.exit(1) self.__proxyGenerated = resultProxyGenerated["Value"] return resultProxyGenerated @@ -169,6 +173,26 @@ def printInfo(self): for userDN in self.__uploadedInfo: gLogger.notice(f" {userDN.ljust(maxDNLen)} | {self.__uploadedInfo[userDN].strftime('%Y/%m/%d %H:%M')}") + def printDebugHint(self): + """Print the certificate details needed for someone else to debug the registration""" + if not self.__piParams.certLoc: + return + chain = X509Chain.X509Chain() + if not chain.loadChainFromFile(self.__piParams.certLoc)["OK"]: + return + # Same certificate as used by ProxyGeneration to find the user DN + cert = chain.getCertInChain(-1)["Value"] + subject = cert.getSubjectDN().get("Value") + issuer = cert.getIssuerDN().get("Value") + if not subject or not issuer: + return + gLogger.notice( + f"\nYour certificate subject (DN) is:\n {subject}\n" + f"and its issuer (CA) is:\n {issuer}\n" + "If you need help, someone with a working proxy can check your registration by running:\n" + f" dirac-admin-debug-user --ca {shlex.quote(issuer)} {shlex.quote(subject)}" + ) + def checkCAs(self): caDir = getCAsLocation() if not caDir: @@ -215,13 +239,19 @@ def doTheMagic(self): return result if not resultProxyWithVOMS["OK"]: - if "returning a valid AC for the user" in resultProxyWithVOMS["Message"]: - gLogger.error(resultProxyWithVOMS["Message"]) - gLogger.error("\n Are you sure you are properly registered in the VO?") - elif "Missing voms-proxy" in resultProxyWithVOMS["Message"]: + message = resultProxyWithVOMS["Message"] + if "Missing voms-proxy" in message: gLogger.notice("Failed to add VOMS extension: no standard grid interface available") else: - gLogger.error(resultProxyWithVOMS["Message"]) + if "returning a valid AC for the user" in message: + message += "\n\nAre you sure you are properly registered in the VO?" + self.debugHintNeeded = True + if self.__piParams.strict: + # Printed by main, avoid showing the error twice + return S_ERROR(message) + gLogger.error(message) + if self.debugHintNeeded: + self.printDebugHint() if self.__piParams.strict: return resultProxyWithVOMS @@ -250,6 +280,8 @@ def main(): resultDoTheMagic = pI.doTheMagic() if not resultDoTheMagic["OK"]: gLogger.fatal(resultDoTheMagic["Message"]) + if pI.debugHintNeeded: + pI.printDebugHint() sys.exit(1) pI.printInfo() From 9bbbbbda0ab2ab4e9cc8b3d359a21873e9324f1d Mon Sep 17 00:00:00 2001 From: Chris Burr Date: Wed, 7 Oct 2026 06:46:44 +0200 Subject: [PATCH 2/2] feat: allow installation specific help messages in dirac-proxy-init Operations//ProxyInit/NotRegisteredMessage and VOMSFailureMessage are shown when the DN is not registered or VOMS refuses to give an AC, for example to link to the VO's documentation. As CFG values can't span multiple lines "\n" is replaced with a line break. (cherry picked from commit c50f27b59673755f82e321245c78cdfa7ed66251) --- dirac.cfg | 9 +++ .../scripts/dirac_proxy_init.py | 57 +++++++++++-------- 2 files changed, 41 insertions(+), 25 deletions(-) diff --git a/dirac.cfg b/dirac.cfg index 0c255e2d89a..f0ebacdc7ba 100644 --- a/dirac.cfg +++ b/dirac.cfg @@ -1164,6 +1164,15 @@ Operations pilotVORepoBranch = master # Branch to use workDir = /tmp/pilot3Files # Local work directory on the masterCS for synchronisation } + # Installation specific help shown by dirac-proxy-init when a proxy can't be created + # Use "\n" for line breaks as values can't span multiple lines + ProxyInit + { + # Shown when the certificate DN is not registered in DIRAC + NotRegisteredMessage = New users must join the VO, see:\n https://example.com/join-the-vo + # Shown when the VOMS server refuses to give the user an attribute certificate + VOMSFailureMessage = Check your certificate is linked to your account in IAM, see:\n https://example.com/link-certificate + } Services { # See http://dirac.readthedocs.io/en/latest/AdministratorGuide/Resources/Catalog/index.html diff --git a/src/DIRAC/FrameworkSystem/scripts/dirac_proxy_init.py b/src/DIRAC/FrameworkSystem/scripts/dirac_proxy_init.py index 6887dcaa7f4..cc1df147cdc 100755 --- a/src/DIRAC/FrameworkSystem/scripts/dirac_proxy_init.py +++ b/src/DIRAC/FrameworkSystem/scripts/dirac_proxy_init.py @@ -16,6 +16,7 @@ import DIRAC from DIRAC import S_ERROR, S_OK, gLogger from DIRAC.ConfigurationSystem.Client.Helpers import Registry +from DIRAC.ConfigurationSystem.Client.Helpers.Operations import Operations from DIRAC.Core.Base.Script import Script from DIRAC.Core.Security import VOMS, ProxyInfo, X509Chain from DIRAC.Core.Security.DiracX import addTokenToPEM @@ -51,7 +52,8 @@ def __init__(self, piParams): self.__issuerCert = False self.__proxyGenerated = False self.__uploadedInfo = {} - self.debugHintNeeded = False + # Name of the ProxyInit/