# SPDX-FileCopyrightText: 2009 Fermi Research Alliance, LLC
# SPDX-License-Identifier: Apache-2.0
import base64
import gzip
import io
import os
import pwd
import re
import shutil
from glideinwms.lib import condorMonitor, logSupport
from glideinwms.lib.defaults import force_bytes
from . import glideFactoryInterface, glideFactoryLib
MY_USERNAME = pwd.getpwuid(os.getuid())[0]
SUPPORTED_AUTH_METHODS = [
"grid_proxy",
"cert_pair",
"key_pair",
"auth_file",
"username_password",
"idtoken",
"scitoken",
]
[docs]
class CredentialError(Exception):
"""defining new exception so that we can catch only the credential errors here
and let the "real" errors propagate up
"""
pass
[docs]
class SubmitCredentials:
"""
Data class containing all information needed to submit a glidein.
"""
def __init__(self, username, security_class):
self.username = username
self.security_class = security_class # Seems redundant info
self.id = None # id used for tracking the submit credentials
self.cred_dir = "" # location of credentials
self.security_credentials = {} # dict of credentials
self.identity_credentials = {} # identity information passed by frontend
[docs]
def add_security_credential(self, cred_type, filename):
"""
Adds a security credential.
"""
if not glideFactoryLib.is_str_safe(filename):
return False
cred_fname = os.path.join(self.cred_dir, "credential_%s" % filename)
if not os.path.isfile(cred_fname):
return False
self.security_credentials[cred_type] = cred_fname
return True
[docs]
def add_factory_credential(self, cred_type, absfname):
"""
Adds a factory provided security credential.
"""
if not os.path.isfile(absfname):
return False
self.security_credentials[cred_type] = absfname
return True
[docs]
def add_identity_credential(self, cred_type, cred_str):
"""
Adds an identity credential.
"""
self.identity_credentials[cred_type] = cred_str
return True
def __repr__(self):
output = "SubmitCredentials"
output += "username = %s; " % self.username
output += "security class = %s; " % str(self.security_class)
output += "id = %s; " % self.id
output += "cedential dir = %s; " % self.cred_dir
output += "security credentials: "
for sck, scv in self.security_credentials.items():
output += f" {sck} : {scv}; "
output += "identity credentials: "
for ick, icv in self.identity_credentials.items():
output += f" {ick} : {icv}; "
return output
[docs]
def update_credential_file(username, client_id, credential_data, request_clientname):
"""
Updates the credential file
:param username: credentials' username
:param client_id: id used for tracking the submit credentials
:param credential_data: the credentials to be advertised
:param request_clientname: client name passed by frontend
:return:the credential file updated
"""
proxy_dir = glideFactoryLib.factoryConfig.get_client_proxies_dir(username)
fname_short = f"credential_{request_clientname}_{glideFactoryLib.escapeParam(client_id)}"
fname = os.path.join(proxy_dir, fname_short)
fname_compressed = "%s_compressed" % fname
fname_mapped_idtoken = "%s_idtoken" % fname
msg = "updating credential file %s" % fname
logSupport.log.debug(msg)
safe_update(fname, credential_data)
compressed_credential = compress_credential(credential_data)
if os.path.exists(fname_mapped_idtoken):
idtoken_data = ""
with open(fname_mapped_idtoken) as idtf:
for line in idtf.readlines():
idtoken_data += line
safe_update(
fname_compressed, b"%s####glidein_credentials=%s" % (force_bytes(idtoken_data), compressed_credential)
)
else:
safe_update(fname_compressed, b"glidein_credentials=%s" % (compressed_credential))
return fname, fname_compressed
# Comment by Igor:
# This functionality should really be in glideFactoryInterface module
# Making a minimal patch now to get the desired functionality
[docs]
def get_globals_classads(factory_collector=glideFactoryInterface.DEFAULT_VAL):
if factory_collector == glideFactoryInterface.DEFAULT_VAL:
factory_collector = glideFactoryInterface.factoryConfig.factory_collector
status_constraint = '(GlideinMyType=?="glideclientglobal")'
status = condorMonitor.CondorStatus("any", pool_name=factory_collector)
status.require_integrity(True) # important, this dictates what gets submitted
status.load(status_constraint)
data = status.fetchStored()
return data
[docs]
def process_global(classad, glidein_descript, frontend_descript):
# Factory public key must exist for decryption
pub_key_obj = glidein_descript.data["PubKeyObj"]
if pub_key_obj is None:
raise CredentialError("Factory has no public key. We cannot decrypt.")
try:
# Get the frontend security name so that we can look up the username
sym_key_obj, frontend_sec_name = validate_frontend(classad, frontend_descript, pub_key_obj)
request_clientname = classad["ClientName"]
# get all the credential ids by filtering keys by regex
# this makes looking up specific values in the dict easier
r = re.compile("^GlideinEncParamSecurityClass")
mkeys = list(filter(r.match, list(classad.keys())))
for key in mkeys:
prefix_len = len("GlideinEncParamSecurityClass")
cred_id = key[prefix_len:]
cred_data = sym_key_obj.decrypt_hex(classad["GlideinEncParam%s" % cred_id])
security_class = sym_key_obj.decrypt_hex(classad[key]).decode("utf-8")
username = frontend_descript.get_username(frontend_sec_name, security_class)
if username is None:
logSupport.log.error(
(
"Cannot find a mapping for credential %s of client %s. Skipping it. The security"
"class field is set to %s in the frontend. Please, verify the glideinWMS.xml and"
" make sure it is mapped correctly"
)
% (cred_id, classad["ClientName"], security_class)
)
continue
msg = "updating credential for %s" % username
logSupport.log.debug(msg)
update_credential_file(username, cred_id, cred_data, request_clientname)
except Exception as e:
logSupport.log.debug(f"\nclassad {classad}\nfrontend_descript {frontend_descript}\npub_key_obj {pub_key_obj})")
error_str = "Error occurred processing the globals classads."
logSupport.log.exception(error_str)
raise CredentialError(error_str) from e
[docs]
def get_key_obj(pub_key_obj, classad):
"""
Gets the symmetric key object from the request classad
@type pub_key_obj: object
@param pub_key_obj: The factory public key object. This contains all the encryption and decryption methods
@type classad: dictionary
@param classad: a dictionary representation of the classad
"""
if "ReqEncKeyCode" in classad:
try:
sym_key_obj = pub_key_obj.extract_sym_key(classad["ReqEncKeyCode"])
return sym_key_obj
except Exception as e:
logSupport.log.debug(f"\nclassad {classad}\npub_key_obj {pub_key_obj}\n")
error_str = "Symmetric key extraction failed."
logSupport.log.exception(error_str)
raise CredentialError(error_str) from e
else:
error_str = "Classad does not contain a key. We cannot decrypt."
raise CredentialError(error_str)
[docs]
def validate_frontend(classad, frontend_descript, pub_key_obj):
"""
Validates that the frontend advertising the classad is allowed and that it
claims to have the same identity that Condor thinks it has.
@type classad: dictionary
@param classad: a dictionary representation of the classad
@type frontend_descript: class object
@param frontend_descript: class object containing all the frontend information
@type pub_key_obj: object
@param pub_key_obj: The factory public key object. This contains all the encryption and decryption methods
@return: sym_key_obj - the object containing the symmetric key used for decryption
@return: frontend_sec_name - the frontend security name, used for determining
the username to use.
"""
# we can get classads from multiple frontends, each with their own
# sym keys. So get the sym_key_obj for each classad
sym_key_obj = get_key_obj(pub_key_obj, classad)
authenticated_identity = classad["AuthenticatedIdentity"]
# verify that the identity that the client claims to be is the identity that Condor thinks it is
try:
enc_identity = sym_key_obj.decrypt_hex(classad["ReqEncIdentity"]).decode("utf-8")
except Exception:
error_str = "Cannot decrypt ReqEncIdentity."
logSupport.log.exception(error_str)
raise CredentialError(error_str)
if enc_identity != authenticated_identity:
error_str = "Client provided invalid ReqEncIdentity(%s!=%s). " "Skipping for security reasons." % (
enc_identity,
authenticated_identity,
)
raise CredentialError(error_str)
try:
frontend_sec_name = sym_key_obj.decrypt_hex(classad["GlideinEncParamSecurityName"]).decode("utf-8")
except Exception:
error_str = "Cannot decrypt GlideinEncParamSecurityName."
logSupport.log.exception(error_str)
raise CredentialError(error_str)
# verify that the frontend is authorized to talk to the factory
expected_identity = frontend_descript.get_identity(frontend_sec_name)
if expected_identity is None:
error_str = "This frontend is not authorized by the factory. Supplied security name: %s" % frontend_sec_name
raise CredentialError(error_str)
if authenticated_identity != expected_identity:
error_str = "This frontend Authenticated Identity, does not match the expected identity"
raise CredentialError(error_str)
return sym_key_obj, frontend_sec_name
[docs]
def check_security_credentials(auth_method, params, client_int_name, entry_name, scitoken_passthru=False):
"""
Verify that only credentials for the given auth method are in the params
Args:
auth_method: (string): authentication method of an entry, defined in the config
params: (dictionary): decrypted params passed in a frontend (client) request
client_int_name (string): internal client name
entry_name: (string): name of the entry
scitoken_passthru: (bool): if True, scitoken present in credential. Override checks
for 'auth_method' and proceded with glidein request
Raises:
CredentialError: if the credentials in params don't match what is defined for the auth method
"""
auth_method_list = auth_method.split("+")
if not set(auth_method_list) & set(SUPPORTED_AUTH_METHODS):
logSupport.log.warning(
"None of the supported auth methods %s in provided auth methods: %s"
% (SUPPORTED_AUTH_METHODS, auth_method_list)
)
return
params_keys = set(params.keys())
relevant_keys = {
"SubmitProxy",
"GlideinProxy",
"Username",
"Password",
"PublicCert",
"PrivateCert",
"PublicKey",
"PrivateKey",
"VMId",
"VMType",
"AuthFile",
}
if "scitoken" in auth_method_list or "frontend_scitoken" in params and scitoken_passthru:
# TODO check validity
# TODO Specifically, Add checks that no undesired credentials are
# sent also when token is used
return
if "grid_proxy" in auth_method_list:
if not scitoken_passthru:
if "SubmitProxy" in params:
# v3+ protocol
valid_keys = {"SubmitProxy"}
invalid_keys = relevant_keys.difference(valid_keys)
if params_keys.intersection(invalid_keys):
raise CredentialError(
"Request from %s has credentials not required by the entry %s, skipping request"
% (client_int_name, entry_name)
)
else:
# No proxy sent
raise CredentialError(
"Request from client %s did not provide a proxy as required by the entry %s, skipping request"
% (client_int_name, entry_name)
)
else:
# Only v3+ protocol supports non grid entries
# Verify that the glidein proxy was provided for non-proxy auth methods
if "GlideinProxy" not in params and not scitoken_passthru:
raise CredentialError("Glidein proxy cannot be found for client %s, skipping request" % client_int_name)
if "cert_pair" in auth_method_list:
# Validate both the public and private certs were passed
if not (("PublicCert" in params) and ("PrivateCert" in params)):
# if not ('PublicCert' in params and 'PrivateCert' in params):
# cert pair is required, cannot service request
raise CredentialError(
"Client '%s' did not specify the certificate pair in the request, this is required by entry %s, skipping "
% (client_int_name, entry_name)
)
# Verify no other credentials were passed
valid_keys = {"GlideinProxy", "PublicCert", "PrivateCert", "VMId", "VMType"}
invalid_keys = relevant_keys.difference(valid_keys)
if params_keys.intersection(invalid_keys):
raise CredentialError(
"Request from %s has credentials not required by the entry %s, skipping request"
% (client_int_name, entry_name)
)
elif "key_pair" in auth_method_list:
# Validate both the public and private keys were passed
if not (("PublicKey" in params) and ("PrivateKey" in params)):
# key pair is required, cannot service request
raise CredentialError(
"Client '%s' did not specify the key pair in the request, this is required by entry %s, skipping "
% (client_int_name, entry_name)
)
# Verify no other credentials were passed
valid_keys = {"GlideinProxy", "PublicKey", "PrivateKey", "VMId", "VMType"}
invalid_keys = relevant_keys.difference(valid_keys)
if params_keys.intersection(invalid_keys):
raise CredentialError(
"Request from %s has credentials not required by the entry %s, skipping request"
% (client_int_name, entry_name)
)
elif "auth_file" in auth_method_list:
# Validate auth_file is passed
if "AuthFile" not in params:
# auth_file is required, cannot service request
raise CredentialError(
"Client '%s' did not specify the auth_file in the request, this is required by entry %s, skipping "
% (client_int_name, entry_name)
)
# Verify no other credentials were passed
valid_keys = {"GlideinProxy", "AuthFile", "VMId", "VMType"}
invalid_keys = relevant_keys.difference(valid_keys)
if params_keys.intersection(invalid_keys):
raise CredentialError(
"Request from %s has credentials not required by the entry %s, skipping request"
% (client_int_name, entry_name)
)
elif "username_password" in auth_method_list:
# Validate username and password keys were passed
if not (("Username" in params) and ("Password" in params)):
# username and password is required, cannot service request
raise CredentialError(
"Client '%s' did not specify the username and password in the request, this is required by entry %s, skipping "
% (client_int_name, entry_name)
)
# Verify no other credentials were passed
valid_keys = {"GlideinProxy", "Username", "Password", "VMId", "VMType"}
invalid_keys = relevant_keys.difference(valid_keys)
if params_keys.intersection(invalid_keys):
raise CredentialError(
"Request from %s has credentials not required by the entry %s, skipping request"
% (client_int_name, entry_name)
)
else:
# should never get here, unsupported main authentication method is checked at the beginning
raise CredentialError("Inconsistency between SUPPORTED_AUTH_METHODS and check_security_credentials")
# No invalid credentials found
return
[docs]
def compress_credential(credential_data):
cfile = io.BytesIO()
f = gzip.GzipFile(fileobj=cfile, mode="wb")
f.write(credential_data)
f.close()
return base64.b64encode(cfile.getvalue())
# TODO: replace comppress_credentials - for when py2.6 is no more supported (v3.7)
# def compress_credential(credential_data):
# with cStringIO.StringIO() as cfile:
# with gzip.GzipFile(fileobj=cfile, mode='wb') as f:
# # Calling a GzipFile object's close() method does not close fileobj, so cfile is available outside
# f.write(credential_data)
# return base64.b64encode(cfile.getvalue())
# TODO: py2.7 , v3.7, add with for the 2 os.open calls
[docs]
def safe_update(fname, credential_data):
logSupport.log.debug(f"Creating/updating credential file {fname}")
if not os.path.isfile(fname):
# new file, create
fd = os.open(fname, os.O_CREAT | os.O_WRONLY, 0o600)
try:
os.write(fd, credential_data)
finally:
os.close(fd)
else:
# old file exists, check if same content
with open(fname) as fl:
old_data = fl.read()
# if proxy_data == old_data nothing changed, done else
if not (credential_data == old_data):
# proxy changed, neeed to update
# remove any previous backup file, if it exists
try:
os.remove(fname + ".old")
except OSError:
pass # just protect
# create new file
fd = os.open(fname + ".new", os.O_CREAT | os.O_WRONLY, 0o600)
try:
os.write(fd, credential_data)
finally:
os.close(fd)
# copy the old file to a tmp bck and rename new one to the official name
try:
shutil.copy2(fname, fname + ".old")
except (OSError, shutil.Error):
# file not found, permission error, same file
pass # just protect
os.rename(fname + ".new", fname)