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/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..cc1df147cdc 100755
--- a/src/DIRAC/FrameworkSystem/scripts/dirac_proxy_init.py
+++ b/src/DIRAC/FrameworkSystem/scripts/dirac_proxy_init.py
@@ -9,12 +9,14 @@
import datetime
import glob
import os
+import shlex
import sys
import time
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
@@ -50,6 +52,8 @@ def __init__(self, piParams):
self.__issuerCert = False
self.__proxyGenerated = False
self.__uploadedInfo = {}
+ # Name of the ProxyInit/