Files
2026-07-13 13:22:34 +08:00

527 lines
17 KiB
Python

import gc
import json
import threading
import time
from concurrent.futures import ThreadPoolExecutor
import pytest
# Commented out pending integration with rest branch:
# from mlflow.entities import SecretResourceType
from mlflow.store.tracking._secret_cache import (
_MAX_TTL,
_MIN_TTL,
EphemeralCacheEncryption,
SecretCache,
)
# Commented out pending integration with rest branch:
# from mlflow.store.tracking.sqlalchemy_store import SqlAlchemyStore
@pytest.fixture
def crypto():
return EphemeralCacheEncryption(ttl_seconds=60)
@pytest.fixture
def cache():
return SecretCache(ttl_seconds=60, max_size=100)
@pytest.mark.parametrize(
"plaintext",
[
"my-secret-api-key-12345",
json.dumps({"api_key": "key123", "region": "us-west-2"}),
json.dumps({"openai_key": "sk-123", "anthropic_key": "claude-api"}),
],
)
def test_encrypt_decrypt_roundtrip(crypto, plaintext):
blob, time_bucket = crypto.encrypt(plaintext)
decrypted = crypto.decrypt(blob, time_bucket)
assert decrypted == plaintext
def test_different_plaintexts_produce_different_ciphertexts(crypto):
blob1, _ = crypto.encrypt("secret1")
blob2, _ = crypto.encrypt("secret2")
assert blob1 != blob2
def test_same_plaintext_produces_different_ciphertexts_due_to_nonce(crypto):
blob1, bucket1 = crypto.encrypt("same-secret")
blob2, bucket2 = crypto.encrypt("same-secret")
assert blob1 != blob2
assert bucket1 == bucket2
def test_decryption_fails_after_key_rotation():
crypto = EphemeralCacheEncryption(ttl_seconds=1)
plaintext = "secret"
blob, time_bucket = crypto.encrypt(plaintext)
time.sleep(2.5)
decrypted = crypto.decrypt(blob, time_bucket)
assert decrypted is None
def test_bucket_key_purged_after_expiration():
crypto = EphemeralCacheEncryption(ttl_seconds=1)
plaintext = "secret-that-should-be-unrecoverable"
blob, time_bucket = crypto.encrypt(plaintext)
assert crypto._active_bucket == time_bucket
assert crypto._active_key is not None
assert len(crypto._active_key) == 32
time.sleep(2.5)
crypto._get_bucket_key(crypto._get_time_bucket())
# After 2.5 seconds with 1s TTL, the original bucket key should be purged
# (it's more than 1 bucket old)
assert crypto._active_bucket != time_bucket
assert crypto._previous_bucket != time_bucket or crypto._previous_bucket is None
assert crypto.decrypt(blob, time_bucket) is None
def test_decryption_succeeds_within_rotation_tolerance():
crypto = EphemeralCacheEncryption(ttl_seconds=1)
plaintext = "secret"
blob, time_bucket = crypto.encrypt(plaintext)
time.sleep(0.5)
decrypted = crypto.decrypt(blob, time_bucket)
assert decrypted == plaintext
def test_decryption_fails_with_corrupted_blob(crypto):
plaintext = "secret"
blob, time_bucket = crypto.encrypt(plaintext)
corrupted_blob = b"corrupted" + blob[9:]
decrypted = crypto.decrypt(corrupted_blob, time_bucket)
assert decrypted is None
def test_cleanup_threads_exit_after_gc():
def _cleanup_threads() -> list[threading.Thread]:
return [t for t in threading.enumerate() if t.name == "EphemeralCacheEncryption-cleanup"]
threads_before = set(_cleanup_threads())
def _create_instances():
for _ in range(5):
c = EphemeralCacheEncryption(ttl_seconds=60)
c.encrypt("test")
_create_instances()
gc.collect()
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
new_threads = [t for t in _cleanup_threads() if t not in threads_before]
if not new_threads:
break
time.sleep(0.1)
else:
raise TimeoutError(f"Cleanup threads still alive after 3s: {new_threads}")
def test_process_ephemeral_keys_unique_per_instance():
crypto1 = EphemeralCacheEncryption(ttl_seconds=60)
crypto2 = EphemeralCacheEncryption(ttl_seconds=60)
# Each instance generates independent random bucket keys
# so one instance cannot decrypt what another encrypted
blob, bucket = crypto1.encrypt("secret")
decrypted_by_crypto2 = crypto2.decrypt(blob, bucket)
assert decrypted_by_crypto2 is None
def test_cache_miss_returns_none(cache):
result = cache.get("SCORER_JOB:job_123")
assert result is None
def test_cache_hit_returns_value(cache):
cache_key = "SCORER_JOB:job_123"
secret = {"api_key": "secret123"}
cache.set(cache_key, secret)
result = cache.get(cache_key)
assert result == secret
def test_cache_stores_multiple_entries(cache):
cache.set("SCORER_JOB:job_1", {"key": "secret1"})
cache.set("SCORER_JOB:job_2", {"key": "secret2"})
cache.set("GLOBAL:workspace_1", {"key": "secret3"})
assert cache.get("SCORER_JOB:job_1")["key"] == "secret1"
assert cache.get("SCORER_JOB:job_2")["key"] == "secret2"
assert cache.get("GLOBAL:workspace_1")["key"] == "secret3"
def test_lru_eviction_when_max_size_exceeded():
cache = SecretCache(ttl_seconds=60, max_size=3)
cache.set("key_1", {"value": "1"})
cache.set("key_2", {"value": "2"})
cache.set("key_3", {"value": "3"})
assert cache.size() == 3
cache.set("key_4", {"value": "4"})
assert cache.size() == 3
assert cache.get("key_1") is None
assert cache.get("key_4") == {"value": "4"}
def test_lru_promotion_on_access():
cache = SecretCache(ttl_seconds=60, max_size=3)
cache.set("key_1", {"value": "1"})
cache.set("key_2", {"value": "2"})
cache.set("key_3", {"value": "3"})
_ = cache.get("key_1")
cache.set("key_4", {"value": "4"})
assert cache.get("key_1") == {"value": "1"}
assert cache.get("key_2") is None
def test_clear_removes_all_entries(cache):
cache.set("key_1", {"value": "1"})
cache.set("key_2", {"value": "2"})
cache.set("key_3", {"value": "3"})
assert cache.size() == 3
cache.clear()
assert cache.size() == 0
assert cache.get("key_1") is None
assert cache.get("key_2") is None
assert cache.get("key_3") is None
@pytest.mark.parametrize(
("ttl", "should_raise"),
[
(_MIN_TTL - 1, True),
(_MIN_TTL, False),
(60, False),
(_MAX_TTL, False),
(_MAX_TTL + 1, True),
],
)
def test_ttl_validation(ttl, should_raise):
if should_raise:
match = f"Cache TTL must be between {_MIN_TTL} and {_MAX_TTL}"
with pytest.raises(ValueError, match=match):
SecretCache(ttl_seconds=ttl)
else:
cache = SecretCache(ttl_seconds=ttl)
assert cache._ttl == ttl
def test_thread_safety_concurrent_reads(cache):
cache.set("key_1", {"value": "secret"})
def read_cache():
for _ in range(100):
result = cache.get("key_1")
assert result == {"value": "secret"}
with ThreadPoolExecutor(
max_workers=10, thread_name_prefix="test-secret-cache-reads"
) as executor:
futures = [executor.submit(read_cache) for _ in range(10)]
for future in futures:
future.result()
def test_thread_safety_concurrent_writes():
cache = SecretCache(ttl_seconds=60, max_size=1000)
def write_cache(thread_id):
for i in range(50):
cache.set(f"key_{thread_id}_{i}", {"value": f"{thread_id}_{i}"})
with ThreadPoolExecutor(
max_workers=10, thread_name_prefix="test-secret-cache-writes"
) as executor:
futures = [executor.submit(write_cache, i) for i in range(10)]
for future in futures:
future.result()
assert cache.size() <= 1000
def test_thread_safety_concurrent_clear():
cache = SecretCache(ttl_seconds=60, max_size=1000)
for i in range(100):
cache.set(f"key_{i}", {"value": str(i)})
def clear_cache():
cache.clear()
def read_cache():
for _ in range(50):
_ = cache.get("key_0")
with ThreadPoolExecutor(
max_workers=10, thread_name_prefix="test-secret-cache-clear"
) as executor:
futures = [executor.submit(clear_cache) for _ in range(3)]
futures += [executor.submit(read_cache) for _ in range(7)]
for future in futures:
future.result()
def test_cache_handles_unicode_secrets(cache):
unicode_secret = {"key": "🔐🔑 secret with émojis and àccénts"}
cache.set("unicode_key", unicode_secret)
result = cache.get("unicode_key")
assert result == unicode_secret
assert result["key"] == "🔐🔑 secret with émojis and àccénts"
def test_cache_handles_large_secrets(cache):
large_secret = {"key": "x" * 10000}
cache.set("large_key", large_secret)
result = cache.get("large_key")
assert result == large_secret
assert len(result["key"]) == 10000
def test_cache_roundtrip_with_complex_secret(cache):
complex_secret = {
"openai_key": "sk-1234567890",
"anthropic_key": "claude-api-key",
"config": {"region": "us-west-2", "timeout": 30},
}
cache.set("SCORER_JOB:complex_job", complex_secret)
result = cache.get("SCORER_JOB:complex_job")
assert result == complex_secret
def test_cache_isolation_between_resources(cache):
cache.set("SCORER_JOB:job_1", {"key": "secret1"})
cache.set("SCORER_JOB:job_2", {"key": "secret2"})
cache.set("GLOBAL:workspace_1", {"key": "secret3"})
cache.clear()
assert cache.get("SCORER_JOB:job_1") is None
# Integration tests commented out pending rest branch integration:
"""
assert cache.get("SCORER_JOB:job_2") is None
assert cache.get("GLOBAL:workspace_1") is None
def test_sqlalchemy_store_uses_default_constants(tmp_path):
db_uri = f"sqlite:///{tmp_path}/test.db"
store = SqlAlchemyStore(db_uri=db_uri, default_artifact_root=str(tmp_path))
assert store._secret_cache._ttl == DEFAULT_CACHE_TTL
assert store._secret_cache._max_size == DEFAULT_CACHE_MAX_SIZE
assert store._secret_cache._crypto._key_rotation_seconds == DEFAULT_CACHE_TTL
def test_sqlalchemy_store_respects_env_var_config(tmp_path, monkeypatch):
monkeypatch.setenv(SECRETS_CACHE_TTL_ENV_VAR, "120")
monkeypatch.setenv(SECRETS_CACHE_MAX_SIZE_ENV_VAR, "500")
db_uri = f"sqlite:///{tmp_path}/test_env.db"
store = SqlAlchemyStore(db_uri=db_uri, default_artifact_root=str(tmp_path))
assert store._secret_cache._ttl == 120
assert store._secret_cache._max_size == 500
assert store._secret_cache._crypto._key_rotation_seconds == 120
def test_e2e_secret_cache_populated_on_first_fetch(tmp_path, monkeypatch):
monkeypatch.setenv("MLFLOW_SECRETS_KEK_PASSPHRASE", "test-kek-passphrase-32chars-min")
db_uri = f"sqlite:///{tmp_path}/test_e2e.db"
store = SqlAlchemyStore(db_uri=db_uri, default_artifact_root=str(tmp_path))
assert store._secret_cache.size() == 0
store._create_and_bind_secret(
secret_name="api_key",
secret_value={"api_key": "sk-test-12345"},
resource_type=SecretResourceType.SCORER_JOB,
resource_id="job_123",
field_name="OPENAI_API_KEY",
is_shared=False,
created_by="test@example.com",
)
assert store._secret_cache.size() == 0
secrets = store._get_secrets_for_resource(SecretResourceType.SCORER_JOB, "job_123")
assert secrets == {"OPENAI_API_KEY": "sk-test-12345"}
assert store._secret_cache.size() == 1
cache_key = f"{SecretResourceType.SCORER_JOB}:job_123"
cached_value = store._secret_cache.get(cache_key)
assert cached_value is not None
assert json.loads(cached_value) == {"OPENAI_API_KEY": "sk-test-12345"}
secrets_again = store._get_secrets_for_resource(SecretResourceType.SCORER_JOB, "job_123")
assert secrets_again == {"OPENAI_API_KEY": "sk-test-12345"}
assert store._secret_cache.size() == 1
store._secret_cache.clear()
assert store._secret_cache.size() == 0
secrets_after_clear = store._get_secrets_for_resource(SecretResourceType.SCORER_JOB, "job_123")
assert secrets_after_clear == {"OPENAI_API_KEY": "sk-test-12345"}
assert store._secret_cache.size() == 1
cached_value_after_clear = store._secret_cache.get(cache_key)
assert cached_value_after_clear is not None
assert json.loads(cached_value_after_clear) == {"OPENAI_API_KEY": "sk-test-12345"}
def test_e2e_cache_miss_on_key_rotation_falls_back_to_db(tmp_path, monkeypatch):
monkeypatch.setenv("MLFLOW_SECRETS_KEK_PASSPHRASE", "test-kek-passphrase-32chars-min")
db_uri = f"sqlite:///{tmp_path}/test_cache_miss.db"
store = SqlAlchemyStore(db_uri=db_uri, default_artifact_root=str(tmp_path))
store._create_and_bind_secret(
secret_name="api_key",
secret_value={"api_key": "sk-test-12345"},
resource_type=SecretResourceType.SCORER_JOB,
resource_id="job_123",
field_name="OPENAI_API_KEY",
is_shared=False,
created_by="test@example.com",
)
secrets = store._get_secrets_for_resource(SecretResourceType.SCORER_JOB, "job_123")
assert secrets == {"OPENAI_API_KEY": "sk-test-12345"}
assert store._secret_cache.size() == 1
cache_key = f"{SecretResourceType.SCORER_JOB}:job_123"
cached_value = store._secret_cache.get(cache_key)
assert cached_value is not None
old_base_key = store._secret_cache._crypto._base_key
store._secret_cache._crypto._base_key = os.urandom(32)
new_base_key = store._secret_cache._crypto._base_key
assert old_base_key != new_base_key
cached_value_after_rotation = store._secret_cache.get(cache_key)
assert cached_value_after_rotation is None
secrets_after_rotation = store._get_secrets_for_resource(
SecretResourceType.SCORER_JOB, "job_123"
)
assert secrets_after_rotation == {"OPENAI_API_KEY": "sk-test-12345"}
assert store._secret_cache.size() == 1
cached_value_repopulated = store._secret_cache.get(cache_key)
assert cached_value_repopulated is not None
assert json.loads(cached_value_repopulated) == {"OPENAI_API_KEY": "sk-test-12345"}
def _worker_process_fetch_secret(db_uri, worker_id, job_ids, result_queue):
os.environ["MLFLOW_SECRETS_KEK_PASSPHRASE"] = "test-kek-passphrase-32chars-min"
store = SqlAlchemyStore(db_uri=db_uri, default_artifact_root="/tmp")
initial_cache_size = store._secret_cache.size()
fetched_secrets = {}
for job_id in job_ids:
secrets = store._get_secrets_for_resource(SecretResourceType.SCORER_JOB, job_id)
fetched_secrets[job_id] = secrets
final_cache_size = store._secret_cache.size()
cached_keys = {}
for job_id in job_ids:
cache_key = f"{SecretResourceType.SCORER_JOB}:{job_id}"
cached_keys[job_id] = store._secret_cache.get(cache_key)
base_key = store._secret_cache._crypto._base_key
result_queue.put(
{
"worker_id": worker_id,
"initial_cache_size": initial_cache_size,
"final_cache_size": final_cache_size,
"fetched_secrets": fetched_secrets,
"cached_keys": cached_keys,
"base_key": base_key,
}
)
def test_e2e_process_isolation_separate_caches(tmp_path, monkeypatch):
monkeypatch.setenv("MLFLOW_SECRETS_KEK_PASSPHRASE", "test-kek-passphrase-32chars-min")
db_uri = f"sqlite:///{tmp_path}/test_multiprocess.db"
store = SqlAlchemyStore(db_uri=db_uri, default_artifact_root=str(tmp_path))
store._create_and_bind_secret(
secret_name="api_key_123",
secret_value={"api_key": "sk-worker-0-secret"},
resource_type=SecretResourceType.SCORER_JOB,
resource_id="job_123",
field_name="OPENAI_API_KEY",
is_shared=False,
created_by="test@example.com",
)
store._create_and_bind_secret(
secret_name="api_key_456",
secret_value={"api_key": "sk-worker-1-secret"},
resource_type=SecretResourceType.SCORER_JOB,
resource_id="job_456",
field_name="ANTHROPIC_API_KEY",
is_shared=False,
created_by="test@example.com",
)
store._create_and_bind_secret(
secret_name="shared_key",
secret_value={"api_key": "sk-shared-secret"},
resource_type=SecretResourceType.SCORER_JOB,
resource_id="job_789",
field_name="SHARED_KEY",
is_shared=False,
created_by="test@example.com",
)
result_queue = multiprocessing.Queue()
worker_jobs = [
["job_123", "job_789"],
["job_456", "job_789"],
]
processes = []
for i in range(2):
p = multiprocessing.Process(
target=_worker_process_fetch_secret, args=(db_uri, i, worker_jobs[i], result_queue)
)
p.start()
processes.append(p)
for p in processes:
p.join(timeout=10)
assert p.exitcode == 0
results = []
while not result_queue.empty():
results.append(result_queue.get())
assert len(results) == 2
worker_0 = next(r for r in results if r["worker_id"] == 0)
worker_1 = next(r for r in results if r["worker_id"] == 1)
assert worker_0["initial_cache_size"] == 0
assert worker_0["final_cache_size"] == 2
assert worker_0["fetched_secrets"]["job_123"] == {"OPENAI_API_KEY": "sk-worker-0-secret"}
assert worker_0["fetched_secrets"]["job_789"] == {"SHARED_KEY": "sk-shared-secret"}
assert worker_0["cached_keys"]["job_123"] is not None
assert worker_0["cached_keys"]["job_789"] is not None
assert worker_1["initial_cache_size"] == 0
assert worker_1["final_cache_size"] == 2
assert worker_1["fetched_secrets"]["job_456"] == {"ANTHROPIC_API_KEY": "sk-worker-1-secret"}
assert worker_1["fetched_secrets"]["job_789"] == {"SHARED_KEY": "sk-shared-secret"}
assert worker_1["cached_keys"]["job_456"] is not None
assert worker_1["cached_keys"]["job_789"] is not None
assert worker_0["base_key"] != worker_1["base_key"]
"""