|
| 1 | +# -*- coding: utf-8 -*- |
| 2 | +import copy |
| 3 | +import os |
| 4 | +import subprocess |
| 5 | +import tempfile |
| 6 | +import requests |
| 7 | +from requests.adapters import HTTPAdapter |
| 8 | +from urllib3.util.ssl_ import create_urllib3_context |
| 9 | + |
| 10 | +import pytest |
| 11 | + |
| 12 | +from ydb.tests.library.harness.kikimr_config import KikimrConfigGenerator |
| 13 | + |
| 14 | + |
| 15 | +class MTLSAdapter(HTTPAdapter): |
| 16 | + """Custom HTTPAdapter that uses client certificates for mTLS""" |
| 17 | + |
| 18 | + def __init__(self, cert_file, key_file, ca_file, *args, **kwargs): |
| 19 | + self.cert_file = cert_file |
| 20 | + self.key_file = key_file |
| 21 | + self.ca_file = ca_file |
| 22 | + super(MTLSAdapter, self).__init__(*args, **kwargs) |
| 23 | + |
| 24 | + def init_poolmanager(self, *args, **kwargs): |
| 25 | + ctx = create_urllib3_context() |
| 26 | + ctx.load_verify_locations(cafile=self.ca_file) |
| 27 | + ctx.load_cert_chain(certfile=self.cert_file, keyfile=self.key_file) |
| 28 | + kwargs['ssl_context'] = ctx |
| 29 | + return super(MTLSAdapter, self).init_poolmanager(*args, **kwargs) |
| 30 | + |
| 31 | + |
| 32 | +def _generate_client_certificate(certs_tmp_dir, client_name, cn_value, ca_crt, ca_key): |
| 33 | + client_key = os.path.join(certs_tmp_dir, f'{client_name}.key') |
| 34 | + client_crt = os.path.join(certs_tmp_dir, f'{client_name}.crt') |
| 35 | + client_csr = os.path.join(certs_tmp_dir, f'{client_name}.csr') |
| 36 | + |
| 37 | + subprocess.run(['openssl', 'genrsa', '-out', client_key, '2048'], check=True, capture_output=True) |
| 38 | + subprocess.run( |
| 39 | + [ |
| 40 | + 'openssl', |
| 41 | + 'req', |
| 42 | + '-new', |
| 43 | + '-key', |
| 44 | + client_key, |
| 45 | + '-out', |
| 46 | + client_csr, |
| 47 | + '-subj', |
| 48 | + f'/CN={cn_value}/O=YDB/C=RU', |
| 49 | + ], |
| 50 | + check=True, |
| 51 | + capture_output=True, |
| 52 | + ) |
| 53 | + subprocess.run( |
| 54 | + [ |
| 55 | + 'openssl', |
| 56 | + 'x509', |
| 57 | + '-req', |
| 58 | + '-in', |
| 59 | + client_csr, |
| 60 | + '-CA', |
| 61 | + ca_crt, |
| 62 | + '-CAkey', |
| 63 | + ca_key, |
| 64 | + '-CAcreateserial', |
| 65 | + '-out', |
| 66 | + client_crt, |
| 67 | + '-days', |
| 68 | + '3650', |
| 69 | + '-sha256', |
| 70 | + ], |
| 71 | + check=True, |
| 72 | + capture_output=True, |
| 73 | + ) |
| 74 | + return client_key, client_crt |
| 75 | + |
| 76 | + |
| 77 | +def generate_certificates(certs_tmp_dir): |
| 78 | + ca_key = os.path.join(certs_tmp_dir, 'server_ca.key') |
| 79 | + ca_crt = os.path.join(certs_tmp_dir, 'server_ca.crt') |
| 80 | + |
| 81 | + subprocess.run(['openssl', 'genrsa', '-out', ca_key, '2048'], check=True, capture_output=True) |
| 82 | + |
| 83 | + subprocess.run( |
| 84 | + [ |
| 85 | + 'openssl', |
| 86 | + 'req', |
| 87 | + '-x509', |
| 88 | + '-new', |
| 89 | + '-nodes', |
| 90 | + '-key', |
| 91 | + ca_key, |
| 92 | + '-sha256', |
| 93 | + '-days', |
| 94 | + '3650', |
| 95 | + '-out', |
| 96 | + ca_crt, |
| 97 | + '-subj', |
| 98 | + '/CN=Monitoring CA/O=YDB/C=RU', |
| 99 | + ], |
| 100 | + check=True, |
| 101 | + capture_output=True, |
| 102 | + ) |
| 103 | + |
| 104 | + # Generate server certificate with localhost in SAN |
| 105 | + server_key = os.path.join(certs_tmp_dir, 'server.key') |
| 106 | + server_crt = os.path.join(certs_tmp_dir, 'server.crt') |
| 107 | + server_csr = os.path.join(certs_tmp_dir, 'server.csr') |
| 108 | + server_conf = os.path.join(certs_tmp_dir, 'server.conf') |
| 109 | + |
| 110 | + subprocess.run(['openssl', 'genrsa', '-out', server_key, '2048'], check=True, capture_output=True) |
| 111 | + |
| 112 | + # Create OpenSSL config file with v3_req section and SAN |
| 113 | + with open(server_conf, 'w') as f: |
| 114 | + f.write('[req]\n') |
| 115 | + f.write('distinguished_name = req_distinguished_name\n') |
| 116 | + f.write('req_extensions = v3_req\n') |
| 117 | + f.write('\n') |
| 118 | + f.write('[req_distinguished_name]\n') |
| 119 | + f.write('\n') |
| 120 | + f.write('[v3_req]\n') |
| 121 | + f.write('subjectAltName=DNS:localhost,DNS:test-server,IP:127.0.0.1\n') |
| 122 | + |
| 123 | + # Generate CSR |
| 124 | + subprocess.run( |
| 125 | + [ |
| 126 | + 'openssl', |
| 127 | + 'req', |
| 128 | + '-new', |
| 129 | + '-key', |
| 130 | + server_key, |
| 131 | + '-out', |
| 132 | + server_csr, |
| 133 | + '-subj', |
| 134 | + '/CN=test-server/O=YDB/C=RU', |
| 135 | + '-config', |
| 136 | + server_conf, |
| 137 | + ], |
| 138 | + check=True, |
| 139 | + capture_output=True, |
| 140 | + ) |
| 141 | + |
| 142 | + # Generate server certificate signed by CA |
| 143 | + subprocess.run( |
| 144 | + [ |
| 145 | + 'openssl', |
| 146 | + 'x509', |
| 147 | + '-req', |
| 148 | + '-in', |
| 149 | + server_csr, |
| 150 | + '-CA', |
| 151 | + ca_crt, |
| 152 | + '-CAkey', |
| 153 | + ca_key, |
| 154 | + '-CAcreateserial', |
| 155 | + '-out', |
| 156 | + server_crt, |
| 157 | + '-days', |
| 158 | + '3650', |
| 159 | + '-sha256', |
| 160 | + '-extensions', |
| 161 | + 'v3_req', |
| 162 | + '-extfile', |
| 163 | + server_conf, |
| 164 | + ], |
| 165 | + check=True, |
| 166 | + capture_output=True, |
| 167 | + ) |
| 168 | + |
| 169 | + admin_client_key, admin_client_crt = _generate_client_certificate( |
| 170 | + certs_tmp_dir, 'admin_client', 'administration_allowed', ca_crt, ca_key |
| 171 | + ) |
| 172 | + |
| 173 | + viewer_client_key, viewer_client_crt = _generate_client_certificate( |
| 174 | + certs_tmp_dir, 'viewer_client', 'viewer_client', ca_crt, ca_key |
| 175 | + ) |
| 176 | + |
| 177 | + return { |
| 178 | + 'server_cert': server_crt, |
| 179 | + 'server_key': server_key, |
| 180 | + 'server_ca': ca_crt, |
| 181 | + 'admin_client_crt': admin_client_crt, |
| 182 | + 'admin_client_key': admin_client_key, |
| 183 | + 'viewer_client_crt': viewer_client_crt, |
| 184 | + 'viewer_client_key': viewer_client_key, |
| 185 | + } |
| 186 | + |
| 187 | + |
| 188 | +CLUSTER_CONFIG = dict( |
| 189 | + enforce_user_token_requirement=True, |
| 190 | + default_clusteradmin='root@builtin', |
| 191 | +) |
| 192 | + |
| 193 | + |
| 194 | +@pytest.fixture(scope='module') |
| 195 | +def ydb_cluster_configuration(): |
| 196 | + conf = copy.deepcopy(CLUSTER_CONFIG) |
| 197 | + return conf |
| 198 | + |
| 199 | + |
| 200 | +@pytest.fixture(scope='module') |
| 201 | +def certificates(): |
| 202 | + certs_tmp_dir = tempfile.mkdtemp(prefix='monitoring_certs_') |
| 203 | + return generate_certificates(certs_tmp_dir) |
| 204 | + |
| 205 | + |
| 206 | +@pytest.fixture(scope='module') |
| 207 | +def client_certificates(certificates): |
| 208 | + """Extract client certificates from certificates""" |
| 209 | + return { |
| 210 | + 'admin_cert': certificates['admin_client_crt'], |
| 211 | + 'admin_key': certificates['admin_client_key'], |
| 212 | + 'viewer_cert': certificates['viewer_client_crt'], |
| 213 | + 'viewer_key': certificates['viewer_client_key'], |
| 214 | + 'ca': certificates['server_ca'], |
| 215 | + } |
| 216 | + |
| 217 | + |
| 218 | +@pytest.fixture(scope='module') |
| 219 | +def ydb_configurator(ydb_cluster_configuration, certificates): |
| 220 | + config_generator = KikimrConfigGenerator(**ydb_cluster_configuration) |
| 221 | + |
| 222 | + config_generator.yaml_config['client_certificate_authorization'] = { |
| 223 | + 'client_certificate_definitions': [ |
| 224 | + { |
| 225 | + 'member_groups': ['AdministrationClientAuth@cert'], |
| 226 | + 'subject_terms': [{'short_name': 'CN', 'values': ['administration_allowed']}], |
| 227 | + }, |
| 228 | + { |
| 229 | + 'member_groups': ['ViewerClientAuth@cert'], |
| 230 | + 'subject_terms': [{'short_name': 'CN', 'values': ['viewer_client']}], |
| 231 | + }, |
| 232 | + ] |
| 233 | + } |
| 234 | + |
| 235 | + security_config = config_generator.yaml_config['domains_config']['security_config'] |
| 236 | + |
| 237 | + if 'administration_allowed_sids' not in security_config: |
| 238 | + security_config['administration_allowed_sids'] = [] |
| 239 | + security_config['administration_allowed_sids'].append('AdministrationClientAuth@cert') |
| 240 | + if 'monitoring_allowed_sids' not in security_config: |
| 241 | + security_config['monitoring_allowed_sids'] = [] |
| 242 | + security_config['monitoring_allowed_sids'].append('AdministrationClientAuth@cert') |
| 243 | + |
| 244 | + if 'grpc_config' not in config_generator.yaml_config: |
| 245 | + config_generator.yaml_config['grpc_config'] = {} |
| 246 | + config_generator.yaml_config['grpc_config']['cert'] = certificates['server_cert'] |
| 247 | + config_generator.yaml_config['grpc_config']['key'] = certificates['server_key'] |
| 248 | + config_generator.yaml_config['grpc_config']['ca'] = certificates['server_ca'] |
| 249 | + |
| 250 | + config_generator.monitoring_tls_cert_path = certificates['server_cert'] |
| 251 | + config_generator.monitoring_tls_key_path = certificates['server_key'] |
| 252 | + config_generator.monitoring_tls_ca_path = certificates['server_ca'] |
| 253 | + |
| 254 | + return config_generator |
| 255 | + |
| 256 | + |
| 257 | +def _test_endpoint_with_certificate(trace_endpoint, client_certificates, cert_type, expected_status): |
| 258 | + session = requests.Session() |
| 259 | + adapter = MTLSAdapter( |
| 260 | + cert_file=client_certificates[f'{cert_type}_cert'], |
| 261 | + key_file=client_certificates[f'{cert_type}_key'], |
| 262 | + ca_file=client_certificates['ca'], |
| 263 | + ) |
| 264 | + session.mount('https://', adapter) |
| 265 | + response = session.get(trace_endpoint, verify=client_certificates['ca'], timeout=5) |
| 266 | + assert ( |
| 267 | + response.status_code == expected_status |
| 268 | + ), f"Expected /trace with {cert_type} cert to return {expected_status}, got {response.status_code}" |
| 269 | + |
| 270 | + |
| 271 | +def _test_endpoint_without_any_auth(trace_endpoint): |
| 272 | + response = requests.get(trace_endpoint, timeout=5, verify=False) |
| 273 | + assert ( |
| 274 | + response.status_code == 401 |
| 275 | + ), f"Expected /trace without auth to return 401, got {response.status_code}" |
| 276 | + |
| 277 | + |
| 278 | +def _test_endpoint_with_token(trace_endpoint, token, expected_status): |
| 279 | + headers = {'Authorization': token} |
| 280 | + response = requests.get(trace_endpoint, headers=headers, timeout=5, verify=False) |
| 281 | + assert ( |
| 282 | + response.status_code == expected_status |
| 283 | + ), f"Expected /trace with token {token} to return {expected_status}, got {response.status_code}" |
| 284 | + |
| 285 | + |
| 286 | +def test_mlts_auth(ydb_cluster, client_certificates): |
| 287 | + host = ydb_cluster.nodes[1].host |
| 288 | + mon_port = ydb_cluster.nodes[1].mon_port |
| 289 | + base_url = f'https://{host}:{mon_port}' |
| 290 | + trace_endpoint = f'{base_url}/trace' |
| 291 | + |
| 292 | + # Without any authentication |
| 293 | + _test_endpoint_without_any_auth(trace_endpoint) |
| 294 | + |
| 295 | + # With viewer certificate |
| 296 | + _test_endpoint_with_certificate(trace_endpoint, client_certificates, 'viewer', 403) |
| 297 | + |
| 298 | + # With administration certificate |
| 299 | + _test_endpoint_with_certificate(trace_endpoint, client_certificates, 'admin', 200) |
| 300 | + |
| 301 | + |
| 302 | +def test_token_auth_when_mtls_is_enabled(ydb_cluster, client_certificates): |
| 303 | + host = ydb_cluster.nodes[1].host |
| 304 | + mon_port = ydb_cluster.nodes[1].mon_port |
| 305 | + base_url = f'https://{host}:{mon_port}' |
| 306 | + trace_endpoint = f'{base_url}/trace' |
| 307 | + |
| 308 | + # Checks that mTLS is enabled |
| 309 | + _test_endpoint_with_certificate(trace_endpoint, client_certificates, 'admin', 200) |
| 310 | + |
| 311 | + # Without any authentication |
| 312 | + _test_endpoint_without_any_auth(trace_endpoint) |
| 313 | + |
| 314 | + # With NOT an admin token |
| 315 | + _test_endpoint_with_token(trace_endpoint, 'user@builtin', 403) |
| 316 | + |
| 317 | + # With an admin token |
| 318 | + _test_endpoint_with_token(trace_endpoint, 'root@builtin', 200) |
0 commit comments