blob: 5b0701dea8e5e5333a48137fe296d04162e90ce1 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you 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.
import base64
import json
import logging
from pathlib import Path
import subprocess
import unittest
from unittest.mock import patch
from openserverless import config, datastore_security as security
class SecurityTests(unittest.TestCase):
def test_debug_logs_never_contain_values(self):
config.clean()
config.configure({'etcd': {'root': {'password': 'sentinel-password'}},
'opaque': {'url': 'http://user:sentinel-uri@internal'},
'state': {'spec': {'secret': 'sentinel-nested'}}})
with self.assertLogs(level=logging.DEBUG) as logs:
config.dump_config()
text = '\n'.join(logs.output)
for value in ['sentinel-password', 'sentinel-uri', 'sentinel-nested']:
self.assertNotIn(value, text)
self.assertIn('etcd.root.password', text)
def test_private_apply_does_not_report_stderr_or_payload(self):
spec = {'kind': 'Secret', 'stringData': {'password': 'sentinel'}}
with patch.object(security.subprocess, 'run', return_value=
subprocess.CompletedProcess([], 1, 'sentinel', 'sentinel')):
with self.assertRaisesRegex(RuntimeError, '^Could not apply private datastore resources$'):
security.apply_private(spec)
def test_secrets_do_not_reach_public_kubectl_wrapper(self):
secret = {'apiVersion': 'v1', 'kind': 'Secret', 'metadata': {'name': 'private'}}
service = {'apiVersion': 'v1', 'kind': 'Service', 'metadata': {'name': 'public'}}
with patch.object(security, 'apply_private') as private, patch.object(security.kube, 'apply') as public:
security.apply({'items': [secret, service]})
self.assertEqual(private.call_args.args[0]['items'], [secret])
self.assertEqual(public.call_args.args[0]['items'], [service])
def test_fresh_signing_keys_are_unique_and_private(self):
captured = []
with patch.object(security.kube, 'kubectl', return_value=''), \
patch.object(security, 'apply_private', side_effect=captured.append):
security.ensure_jwt_key()
security.ensure_jwt_key()
keys = [base64.b64decode(x['data']['jwt-token.pem']) for x in captured]
self.assertNotEqual(keys[0], keys[1])
self.assertTrue(all(b'PRIVATE KEY' in key for key in keys))
def test_existing_key_is_preserved(self):
key = subprocess.run(['openssl', 'genpkey', '-algorithm', 'RSA',
'-pkeyopt', 'rsa_keygen_bits:2048'], capture_output=True, check=True).stdout
raw = json.dumps({'data': {'jwt-token.pem': base64.b64encode(key).decode()}})
with patch.object(security.kube, 'kubectl', return_value=raw), \
patch.object(security, 'apply_private') as apply:
security.ensure_jwt_key()
apply.assert_not_called()
def test_invalid_key_is_not_replaced(self):
raw = json.dumps({'data': {'jwt-token.pem': base64.b64encode(b'not-a-key').decode()}})
with patch.object(security.kube, 'kubectl', return_value=raw), \
patch.object(security, 'apply_private') as apply:
with self.assertRaises(RuntimeError):
security.ensure_jwt_key()
apply.assert_not_called()
def test_missing_key_on_patched_cluster_is_not_reset(self):
sts = {'spec': {'template': {'spec': {'volumes': [
{'secret': {'secretName': 'openserverless-etcd-jwt-token-v2'}}]}}}}
with patch.object(security.kube, 'kubectl', side_effect=['', json.dumps(sts)]), \
patch.object(security, 'apply_private') as apply:
with self.assertRaises(RuntimeError):
security.ensure_jwt_key()
apply.assert_not_called()
def test_monitoring_does_not_allow_all_peers(self):
rules = security.policy('milvus')['spec']['ingress']
metrics = [rule for rule in rules if any(p['port'] == 9091 for p in rule['ports'])]
self.assertEqual(len(metrics), 1)
self.assertTrue(all(peer['podSelector']['matchLabels'] for peer in metrics[0]['from']))
self.assertFalse(any('namespaceSelector' in peer for peer in metrics[0]['from']))
def test_etcd_peer_port_is_restricted_to_members(self):
rules = security.policy('etcd')['spec']['ingress']
peer_rule = [rule for rule in rules if any(p['port'] == 2380 for p in rule['ports'])]
self.assertEqual(len(peer_rule), 1)
self.assertEqual(peer_rule[0]['from'], [{'podSelector': {'matchLabels': {
'app.kubernetes.io/name': 'openserverless-etcd'}}}])
def test_secret_templates_cover_full_and_slim(self):
import jinja2
import jinja2.meta
import yaml
root = Path(__file__).resolve().parents[1]
for mode in ['base', 'slim-base']:
path = root/'openserverless/templates'/('milvus-cfg-'+mode+'.yaml')
source = path.read_text()
variables = jinja2.meta.find_undeclared_variables(jinja2.Environment().parse(source))
data = {name: 'sentinel' for name in variables}
data.update(etcd_range=[0, 1, 2], milvus_etcd_username='etcdmilvus',
milvus_etcd_password='punctuation:"\\\n', milvus_max_database_num=64,
milvus_max_role_num=128, milvus_max_user_num=128)
obj = yaml.safe_load(jinja2.Template(source).render(**data))
self.assertEqual(obj['kind'], 'Secret')
cfg = yaml.safe_load(obj['stringData']['default.yaml'])
self.assertEqual(cfg['etcd']['auth']['userName'], 'etcdmilvus')
self.assertEqual(cfg['etcd']['auth']['password'], data['milvus_etcd_password'])
self.assertNotEqual(cfg['etcd']['auth']['userName'], 'root')
def test_existing_system_user_is_verified_without_recreation(self):
from openserverless import milvus_standalone as milvus
data = {'openserverless_password': 'synthetic'}
with patch.object(milvus, 'MilvusAdminClient') as admin, \
patch('openserverless.milvus_simple_client.MilvusSimpleClient') as client, \
patch('requests.post') as post, patch.object(milvus, '_annotate_nuv_milvus_metadata'):
client.return_value.list_users.return_value = ['root', 'openserverless']
post.return_value.ok = True
post.return_value.json.return_value = {'code': 0, 'data': []}
self.assertTrue(milvus.create_default_milvus_database(data))
admin.return_value.setup_user.assert_not_called()
post.return_value.json.return_value = {'code': 1800, 'message': 'denied'}
self.assertFalse(milvus.create_default_milvus_database(data))
admin.return_value.setup_user.assert_not_called()
def test_scoped_account_accepts_only_its_prefix_and_exact_health_read(self):
import contextlib
try:
from openserverless import etcd_admin as admin
except ImportError:
from openserverless import etcd as admin
def operation(session, endpoint, path, body, **kwargs):
if path == 'auth/user/get': return {'roles': ['probe_role']}
if path == 'auth/role/get': return {'perm': [
{'key': base64.b64encode(b'health').decode()},
{'permType': 'READWRITE', 'key': base64.b64encode(b'milvus/').decode(),
'range_end': base64.b64encode(b'milvus0').decode()}]}
return {}
with patch.object(admin, '_admin', return_value=contextlib.nullcontext((object(), 'endpoint'))), \
patch.object(admin, '_post', side_effect=operation):
self.assertTrue(admin.create_etcd_user('probe', 'quoted:\"\n', 'milvus', readonly_keys=('health',)))
# The existing health permission is not silently accepted for a generic user.
self.assertFalse(admin.create_etcd_user('probe', 'quoted', 'milvus'))
def test_reconciliation_refuses_an_existing_root_role(self):
import contextlib
try:
from openserverless import etcd_admin as admin
except ImportError:
from openserverless import etcd as admin
def operation(session, endpoint, path, body, **kwargs):
if path == 'auth/user/get': return {'roles': ['root', 'probe_role']}
if path == 'auth/role/get': return {'perm': []}
return {}
with patch.object(admin, '_admin', return_value=contextlib.nullcontext((object(), 'endpoint'))), \
patch.object(admin, '_post', side_effect=operation):
self.assertFalse(admin.create_etcd_user('probe', 'synthetic', 'milvus'))
if __name__ == '__main__':
import jinja2.meta
unittest.main()