You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
454 lines
16 KiB
454 lines
16 KiB
#
|
|
# Copyright 2025 Splunk Inc.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
#
|
|
|
|
"""Credentials Management for REST Endpoint
|
|
"""
|
|
|
|
|
|
import json
|
|
import urllib.parse
|
|
|
|
from solnlib.credentials import CredentialManager, CredentialNotExistException
|
|
|
|
from .error import RestError
|
|
from .util import get_base_app_name
|
|
|
|
__all__ = [
|
|
"RestCredentialsContext",
|
|
"RestCredentials",
|
|
]
|
|
|
|
|
|
class RestCredentialsContext:
|
|
"""
|
|
Credentials' context, including realm, username and password.
|
|
"""
|
|
|
|
REALM = "__REST_CREDENTIAL__#{base_app}#{endpoint}"
|
|
|
|
def __init__(self, endpoint, name, *args, **kwargs):
|
|
self._endpoint = endpoint
|
|
self._name = name
|
|
self._args = args
|
|
self._kwargs = kwargs
|
|
|
|
def realm(self):
|
|
"""
|
|
RestCredentials context ``realm``.
|
|
:return:
|
|
"""
|
|
return self.REALM.format(
|
|
base_app=get_base_app_name(),
|
|
endpoint=self._endpoint.internal_endpoint.strip("/"),
|
|
)
|
|
|
|
def username(self):
|
|
"""
|
|
RestCredentials context ``username``.
|
|
:return:
|
|
"""
|
|
return self._name
|
|
|
|
def dump(self, data):
|
|
"""
|
|
RestCredentials context ``password``.
|
|
Dump data to string.
|
|
:param data: data to be encrypted
|
|
:type data: dict
|
|
:return:
|
|
"""
|
|
return json.dumps(data)
|
|
|
|
def load(self, string):
|
|
"""
|
|
RestCredentials context ``password``.
|
|
Load data from string.
|
|
:param string: data has been decrypted
|
|
:type string: str
|
|
:return:
|
|
"""
|
|
try:
|
|
return json.loads(string)
|
|
except ValueError:
|
|
raise RestError(500, "Fail to load encrypted string, invalid JSON")
|
|
|
|
|
|
class RestCredentials:
|
|
"""
|
|
Credential Management stored in passwords.conf
|
|
"""
|
|
|
|
# Changed password constant to six '*' to make it consistent with solnlib password constant
|
|
PASSWORD = "******"
|
|
EMPTY_VALUE = ""
|
|
|
|
def __init__(self, splunkd_uri, session_key, endpoint):
|
|
self._splunkd_uri = splunkd_uri
|
|
self._splunkd_info = urllib.parse.urlparse(self._splunkd_uri)
|
|
self._session_key = session_key
|
|
self._endpoint = endpoint
|
|
self._realm = "__REST_CREDENTIAL__#{base_app}#{endpoint}".format(
|
|
base_app=get_base_app_name(),
|
|
endpoint=self._endpoint.internal_endpoint.strip("/"),
|
|
)
|
|
|
|
def get_encrypted_field_names(self, name):
|
|
return [x.name for x in self._endpoint.model(name).fields if x.encrypted]
|
|
|
|
def encrypt_for_create(self, name, data):
|
|
"""
|
|
force to encrypt all fields that need to be encrypted
|
|
used for create scenarios
|
|
:param name:
|
|
:param data:
|
|
:return:
|
|
"""
|
|
encrypted_field_names = self.get_encrypted_field_names(name)
|
|
encrypting = {}
|
|
for field_name in encrypted_field_names:
|
|
if field_name in data and data[field_name]:
|
|
# if it exist in data and it's not empty,
|
|
# encrypt it and set original value as "****..."
|
|
encrypting[field_name] = data[field_name]
|
|
data[field_name] = self.PASSWORD
|
|
|
|
if encrypting:
|
|
# only save credential when the stanza is existing in
|
|
# passwords.conf or encrypting data is not empty
|
|
self._set(name, encrypting)
|
|
|
|
def encrypt_for_update(self, name, data):
|
|
"""
|
|
|
|
:param name:
|
|
:param data:
|
|
:return:
|
|
"""
|
|
encrypted_field_names = self.get_encrypted_field_names(name)
|
|
encrypting = {}
|
|
if not encrypted_field_names:
|
|
# return if there are not encrypted fields
|
|
return
|
|
for field_name in encrypted_field_names:
|
|
if field_name in data and data[field_name]:
|
|
if data[field_name] != self.PASSWORD:
|
|
# if the field in data and not empty and it's not '*******', encrypted it
|
|
encrypting[field_name] = data[field_name]
|
|
data[field_name] = self.PASSWORD
|
|
else:
|
|
# if the field value is '******', keep the original value
|
|
try:
|
|
original_clear_password = self._get(name)
|
|
except CredentialNotExistException:
|
|
original_clear_password = None
|
|
if original_clear_password and original_clear_password.get(
|
|
field_name
|
|
):
|
|
encrypting[field_name] = original_clear_password[field_name]
|
|
else:
|
|
# original password does not exist, use '******' as password
|
|
encrypting[field_name] = data[field_name]
|
|
elif field_name in data and not data[field_name]:
|
|
data[field_name] = ""
|
|
else:
|
|
# field not in data
|
|
# if the optional encrypted field is not passed, keep original if it exist
|
|
try:
|
|
original_clear_password = self._get(name)
|
|
except CredentialNotExistException:
|
|
original_clear_password = None
|
|
if original_clear_password and original_clear_password.get(field_name):
|
|
encrypting[field_name] = original_clear_password[field_name]
|
|
data[field_name] = self.PASSWORD
|
|
|
|
if encrypting:
|
|
self._set(name, encrypting)
|
|
else:
|
|
self.delete(name)
|
|
|
|
def decrypt_for_get(self, name, data):
|
|
"""
|
|
encrypt password if conf changed and return data that needs to write back to conf
|
|
:param name:
|
|
:param data:
|
|
:return:
|
|
"""
|
|
data_need_write_to_conf = dict()
|
|
# password dict needs to be encrypted
|
|
encrypting = dict()
|
|
encrypted_field_names = self.get_encrypted_field_names(name)
|
|
if not encrypted_field_names:
|
|
return
|
|
try:
|
|
# try to get clear password for the entity
|
|
clear_password = self._get(name)
|
|
# password exist for the entity
|
|
for field_name in encrypted_field_names:
|
|
if field_name in data and data[field_name]:
|
|
if data[field_name] != self.PASSWORD:
|
|
# if the field exist in data and not equals to '*******'
|
|
# add to dict to be encrypted, else treat it as unchanged
|
|
encrypting[field_name] = data[field_name]
|
|
data_need_write_to_conf[field_name] = self.PASSWORD
|
|
|
|
else:
|
|
# get clear password for the field
|
|
data[field_name] = clear_password[field_name]
|
|
encrypting[field_name] = clear_password[field_name]
|
|
|
|
if encrypting and clear_password != encrypting:
|
|
# update passwords.conf if password changed
|
|
self._set(name, encrypting)
|
|
except CredentialNotExistException:
|
|
# password does not exist for the entity
|
|
for field_name in encrypted_field_names:
|
|
if field_name in data and data[field_name]:
|
|
if data[field_name] != self.PASSWORD:
|
|
# if the field exist in data and not equals to '*******'
|
|
# add to dict to be encrypted
|
|
encrypting[field_name] = data[field_name]
|
|
data_need_write_to_conf[field_name] = self.PASSWORD
|
|
else:
|
|
# treat '*******' as password
|
|
encrypting[field_name] = self.PASSWORD
|
|
|
|
if encrypting:
|
|
# set passwords.conf if encrypting data is not empty
|
|
self._set(name, encrypting)
|
|
|
|
return data_need_write_to_conf
|
|
|
|
def encrypt(self, name, data):
|
|
"""
|
|
|
|
:param name:
|
|
:param data:
|
|
:return:
|
|
"""
|
|
# Check if encrypt is needed
|
|
model = self._endpoint.model(name)
|
|
need_encrypting = all(field.encrypted for field in model.fields)
|
|
if not need_encrypting:
|
|
return
|
|
try:
|
|
encrypted = self._get(name)
|
|
existing = True
|
|
except CredentialNotExistException:
|
|
encrypted = {}
|
|
existing = False
|
|
encrypting = self._filter(name, data, encrypted)
|
|
self._merge(name, encrypted, encrypting)
|
|
if existing or encrypting:
|
|
# only save credential when the stanza is existing in
|
|
# passwords.conf or encrypting data is not empty
|
|
self._set(name, encrypting)
|
|
|
|
def decrypt(self, name, data):
|
|
"""
|
|
|
|
:param name:
|
|
:param data:
|
|
:return: If the passwords.conf is updated, masked data.
|
|
Else, None.
|
|
"""
|
|
try:
|
|
# clear password object loads from json
|
|
encrypted = self._get(name)
|
|
existing = True
|
|
except CredentialNotExistException:
|
|
encrypted = {}
|
|
existing = False
|
|
# get fields to be encrypted
|
|
encrypting = self._filter(name, data, encrypted)
|
|
self._merge(name, encrypted, encrypting)
|
|
if existing or encrypting:
|
|
# only save credential when the stanza is existing in
|
|
# passwords.conf or encrypting data is not empty
|
|
self._set(name, encrypting)
|
|
data.update(encrypting)
|
|
return encrypted
|
|
|
|
def decrypt_all(self, data):
|
|
"""
|
|
:param data:
|
|
:return: changed stanza list
|
|
"""
|
|
credential_manager = CredentialManager(
|
|
self._session_key,
|
|
owner=self._endpoint.user,
|
|
app=self._endpoint.app,
|
|
realm=self._realm,
|
|
scheme=self._splunkd_info.scheme,
|
|
host=self._splunkd_info.hostname,
|
|
port=self._splunkd_info.port,
|
|
)
|
|
all_passwords = credential_manager.get_clear_passwords_in_realm()
|
|
realm_passwords = [x for x in all_passwords if x["realm"] == self._realm]
|
|
return self._merge_passwords(data, realm_passwords)
|
|
|
|
@staticmethod
|
|
def _delete_empty_value_for_dict(dct):
|
|
empty_value_names = [k for k, v in dct.items() if v == ""]
|
|
for k in empty_value_names:
|
|
del dct[k]
|
|
|
|
def _merge_passwords(self, data, passwords):
|
|
"""
|
|
return if some fields need to write with new "******"
|
|
"""
|
|
# merge clear passwords to response data
|
|
changed_item_list = []
|
|
|
|
password_dict = {
|
|
pwd["username"]: json.loads(pwd["clear_password"]) for pwd in passwords
|
|
}
|
|
# existed passwords models: previously has encrypted value
|
|
existing_encrypted_items = [x for x in data if x["name"] in password_dict]
|
|
|
|
# previously has no encrypted value
|
|
not_encrypted_items = [x for x in data if x["name"] not in password_dict]
|
|
|
|
# For model that password existed
|
|
# 1.Password changed: Update it and add to changed_item_list
|
|
# 2.Password unchanged: Get the password and update the response data
|
|
for existed_model in existing_encrypted_items:
|
|
name = existed_model["name"]
|
|
clear_password = password_dict[name]
|
|
need_write_magic_pwd = False
|
|
need_write_back_pwd = False
|
|
for k, v in clear_password.items():
|
|
# make sure key exist in model content
|
|
if k in existed_model["content"]:
|
|
if existed_model["content"][k] == self.PASSWORD:
|
|
# set existing as raw value
|
|
existed_model["content"][k] = v
|
|
elif existed_model["content"][k] == "":
|
|
# mark to delete it
|
|
clear_password[k] = ""
|
|
need_write_back_pwd = True
|
|
continue
|
|
else:
|
|
need_write_magic_pwd = True
|
|
need_write_back_pwd = True
|
|
clear_password[k] = existed_model["content"][k]
|
|
else:
|
|
# mark to delete it
|
|
clear_password[k] = ""
|
|
need_write_back_pwd = True
|
|
|
|
# update the password storage
|
|
if need_write_magic_pwd:
|
|
changed_item_list.append(existed_model)
|
|
|
|
if need_write_back_pwd:
|
|
self._delete_empty_value_for_dict(clear_password)
|
|
if clear_password:
|
|
self._set(name, clear_password)
|
|
else:
|
|
# there's no any pwd any more, directly delete it.
|
|
self.delete(name)
|
|
|
|
# For other models, encrypt the password and return
|
|
for other_model in not_encrypted_items:
|
|
name = other_model["name"]
|
|
content = other_model["content"]
|
|
encrypted_field_names = self.get_encrypted_field_names(name)
|
|
clear_password = {}
|
|
for field_name in encrypted_field_names:
|
|
# make sure key exist in model content
|
|
if field_name in content and content[field_name] != "":
|
|
clear_password[field_name] = content[field_name]
|
|
if clear_password:
|
|
self._set(name, clear_password)
|
|
|
|
changed_item_list.extend(not_encrypted_items)
|
|
return changed_item_list
|
|
|
|
def delete(self, name):
|
|
context = RestCredentialsContext(self._endpoint, name)
|
|
mgr = self._get_manager(context)
|
|
try:
|
|
mgr.delete_password(user=context.username())
|
|
except CredentialNotExistException:
|
|
pass
|
|
|
|
def _set(self, name, credentials):
|
|
if credentials is None:
|
|
return
|
|
context = RestCredentialsContext(self._endpoint, name)
|
|
mgr = self._get_manager(context)
|
|
mgr.set_password(user=context.username(), password=context.dump(credentials))
|
|
|
|
def _get(self, name):
|
|
context = RestCredentialsContext(self._endpoint, name)
|
|
mgr = self._get_manager(context)
|
|
string = mgr.get_password(user=context.username())
|
|
return context.load(string)
|
|
|
|
def _filter(self, name, data, encrypted_data):
|
|
model = self._endpoint.model(name)
|
|
encrypting_data = {}
|
|
for field in model.fields:
|
|
if not field.encrypted:
|
|
# remove non-encrypted fields
|
|
if field.name in encrypted_data:
|
|
del encrypted_data[field.name]
|
|
continue
|
|
if field.name not in data:
|
|
# ignore un-posted fields
|
|
continue
|
|
if data[field.name] == self.PASSWORD:
|
|
# ignore already-encrypted fields
|
|
continue
|
|
if data[field.name] != self.EMPTY_VALUE:
|
|
encrypting_data[field.name] = data[field.name]
|
|
# non-empty fields
|
|
data[field.name] = self.PASSWORD
|
|
if field.name in encrypted_data:
|
|
del encrypted_data[field.name]
|
|
return encrypting_data
|
|
|
|
def _merge(self, name, encrypted, encrypting):
|
|
model = self._endpoint.model(name)
|
|
for field in model.fields:
|
|
if field.encrypted is False:
|
|
continue
|
|
|
|
val_encrypting = encrypting.get(field.name)
|
|
if val_encrypting:
|
|
encrypted[field.name] = self.PASSWORD
|
|
continue
|
|
elif val_encrypting == self.EMPTY_VALUE:
|
|
del encrypting[field.name]
|
|
encrypted[field.name] = self.EMPTY_VALUE
|
|
continue
|
|
|
|
val_encrypted = encrypted.get(field.name)
|
|
if val_encrypted:
|
|
encrypting[field.name] = val_encrypted
|
|
del encrypted[field.name]
|
|
|
|
def _get_manager(self, context):
|
|
return CredentialManager(
|
|
self._session_key,
|
|
owner=self._endpoint.user,
|
|
app=self._endpoint.app,
|
|
realm=context.realm(),
|
|
scheme=self._splunkd_info.scheme,
|
|
host=self._splunkd_info.hostname,
|
|
port=self._splunkd_info.port,
|
|
)
|