7f7868f7cf
---ci--- phase: 5 milestone: v0.1 status: complete requirements: covered: [REQ-01, REQ-02, REQ-03, REQ-04, REQ-05, REQ-06, REQ-07, REQ-08, REQ-09, REQ-10, REQ-11, REQ-12, REQ-13, REQ-14, REQ-15, REQ-16, REQ-17, REQ-18, REQ-19, REQ-20] partial: [] ---/ci---
233 lines
10 KiB
Python
233 lines
10 KiB
Python
import base64
|
|
import contextlib
|
|
import io
|
|
import json
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
import uuid as uuidlib
|
|
|
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)))
|
|
|
|
import coreci_broker
|
|
|
|
ADMIN_USER = "admin"
|
|
ADMIN_PASS = "correct-horse-battery-staple"
|
|
REGISTER_PATH = "/api/broker/agents/register"
|
|
APPS_PATH = "/api/broker/app_registrations"
|
|
USERS_PATH = "/api/broker/user_allowlist"
|
|
AUDIT_PATH = "/api/broker/audit"
|
|
SECRETS_PREFIX = "/api/broker/secrets/"
|
|
AUTH_401_BODY = {"error": 401, "message": "invalid app credentials"}
|
|
PROXMOX_SECRET = {"url": "https://pve.coreci.dev:8006", "token": "pve-tok"}
|
|
GITEA_SECRET = {"url": "https://git.coreci.dev", "token": "git-tok"}
|
|
|
|
|
|
def _basic_header(username, password):
|
|
raw = "%s:%s" % (username, password)
|
|
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("ascii")
|
|
|
|
|
|
class _SecretsCase(unittest.TestCase):
|
|
creds_json = json.dumps(
|
|
{"coreci/proxmox": PROXMOX_SECRET, "coreci/gitea": GITEA_SECRET}
|
|
)
|
|
|
|
def setUp(self):
|
|
tmp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(tmp.cleanup)
|
|
self.tmp = tmp.name
|
|
self.creds_path = os.path.join(self.tmp, "creds.json")
|
|
with open(self.creds_path, "w", encoding="utf-8") as handle:
|
|
handle.write(self.creds_json)
|
|
env = {
|
|
"BROKER_ENV_PATH": os.path.join(self.tmp, ".env"),
|
|
"BROKER_DB_PATH": os.path.join(self.tmp, "broker.db"),
|
|
"BROKER_AUDIT_LOG_PATH": os.path.join(self.tmp, "broker.audit.log"),
|
|
"BROKER_ADMIN_USER": ADMIN_USER,
|
|
"BROKER_ADMIN_PASS": ADMIN_PASS,
|
|
"BROKER_CREDS_SOURCE": "file",
|
|
"BROKER_CREDS_FILE": self.creds_path,
|
|
}
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
self.broker = coreci_broker.boot(env)
|
|
self.addCleanup(self.broker.audit.close)
|
|
self.addCleanup(self.broker.store.close)
|
|
self.server = coreci_broker.serve("127.0.0.1", 0, self.broker)
|
|
self.addCleanup(self.server.shutdown)
|
|
self.addCleanup(self.server.server_close)
|
|
self.base = "http://127.0.0.1:%d" % self.server.server_address[1]
|
|
self.admin = {"Authorization": _basic_header(ADMIN_USER, ADMIN_PASS)}
|
|
self.app_uuid = str(uuidlib.uuid4())
|
|
self.user_id = "ceo"
|
|
self._seed()
|
|
self.registered = self._register("proxmox", ["coreci"])
|
|
self.app = {"Authorization": "App %s %s" % (self.app_uuid, self.registered["token"])}
|
|
|
|
def _request(self, method, path, payload=None, headers=None):
|
|
data = None if payload is None else json.dumps(payload).encode("utf-8")
|
|
request = urllib.request.Request(self.base + path, data=data, method=method)
|
|
for key, value in (headers or {}).items():
|
|
request.add_header(key, value)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=10) as response:
|
|
raw = response.read().decode("utf-8")
|
|
return response.status, json.loads(raw) if raw else None
|
|
except urllib.error.HTTPError as error:
|
|
raw = error.read().decode("utf-8")
|
|
return error.code, json.loads(raw) if raw else None
|
|
|
|
def _seed(self):
|
|
status, _ = self._request("POST", APPS_PATH, {"uuid": self.app_uuid}, self.admin)
|
|
self.assertEqual(201, status)
|
|
status, _ = self._request("POST", USERS_PATH, {"user_id": self.user_id}, self.admin)
|
|
self.assertEqual(201, status)
|
|
|
|
def _register(self, agent_id, tags):
|
|
payload = {"agent_id": agent_id, "app_registration_uuid": self.app_uuid,
|
|
"user_id": self.user_id, "tags": list(tags)}
|
|
status, body = self._request("POST", REGISTER_PATH, payload)
|
|
self.assertEqual(201, status)
|
|
return body
|
|
|
|
def _secret(self, suffix, headers=None):
|
|
return self._request("GET", SECRETS_PREFIX + suffix,
|
|
headers=self.app if headers is None else headers)
|
|
|
|
def _audit(self, action):
|
|
query = urllib.parse.urlencode({"action": action, "limit": "1000"})
|
|
status, body = self._request("GET", AUDIT_PATH + "?" + query, headers=self.admin)
|
|
self.assertEqual(200, status)
|
|
return body["entries"]
|
|
|
|
def _read_entries(self, result):
|
|
return [entry for entry in self._audit("read") if entry["result"] == result]
|
|
|
|
def _assert_identity(self, entry, path, agent_id="proxmox"):
|
|
self.assertEqual("read", entry["action"])
|
|
self.assertEqual(path, entry["path"])
|
|
self.assertEqual(self.user_id, entry["user_id"])
|
|
self.assertEqual(self.app_uuid, entry["app_registration_uuid"])
|
|
self.assertEqual(agent_id, entry["agent_id"])
|
|
|
|
def _ghost_headers(self, agent_id):
|
|
ghost = self._register(agent_id, ["coreci"])
|
|
return {"Authorization": "App %s %s" % (self.app_uuid, ghost["token"])}
|
|
|
|
|
|
class SecretsReadTests(_SecretsCase):
|
|
def test_valid_read_returns_document_and_ok_audit(self):
|
|
status, body = self._secret("coreci/proxmox")
|
|
self.assertEqual(200, status)
|
|
self.assertEqual("coreci/proxmox", body["path"])
|
|
self.assertEqual(PROXMOX_SECRET, body["data"])
|
|
self.assertRegex(body["version"], r"^[0-9a-f]{8}$")
|
|
self.assertIn("updated_at", body)
|
|
entries = self._read_entries("ok")
|
|
self.assertEqual(1, len(entries))
|
|
self._assert_identity(entries[0], "coreci/proxmox")
|
|
|
|
def test_encoded_slash_path_resolves_to_same_secret(self):
|
|
status, body = self._secret("coreci%2Fproxmox")
|
|
self.assertEqual(200, status)
|
|
self.assertEqual("coreci/proxmox", body["path"])
|
|
self.assertEqual(PROXMOX_SECRET, body["data"])
|
|
self.assertEqual(1, len(self._read_entries("ok")))
|
|
|
|
def test_missing_app_header_is_401(self):
|
|
status, body = self._secret("coreci/proxmox", headers={})
|
|
self.assertEqual(401, status)
|
|
self.assertEqual(AUTH_401_BODY, body)
|
|
self.assertEqual([], self._read_entries("ok"))
|
|
self.assertEqual([], self._read_entries("denied"))
|
|
|
|
def test_tampered_token_is_401_with_denied_audit(self):
|
|
token = self.registered["token"]
|
|
twisted = ("x" if token[0] != "x" else "y") + token[1:]
|
|
status, body = self._secret("coreci/proxmox", headers={
|
|
"Authorization": "App %s %s" % (self.app_uuid, twisted)})
|
|
self.assertEqual(401, status)
|
|
self.assertEqual(AUTH_401_BODY, body)
|
|
entries = self._audit("denied")
|
|
self.assertEqual(1, len(entries))
|
|
self.assertEqual("denied", entries[0]["action"])
|
|
self.assertEqual("denied", entries[0]["result"])
|
|
self.assertEqual(self.app_uuid, entries[0]["app_registration_uuid"])
|
|
self.assertIsNone(entries[0]["user_id"])
|
|
self.assertIsNone(entries[0]["agent_id"])
|
|
|
|
def test_cross_path_read_is_403_with_denied_audit(self):
|
|
status, body = self._secret("coreci/gitea")
|
|
self.assertEqual(403, status)
|
|
self.assertEqual(403, body["error"])
|
|
self.assertTrue(body["message"])
|
|
entries = self._read_entries("denied")
|
|
self.assertEqual(1, len(entries))
|
|
self._assert_identity(entries[0], "coreci/gitea")
|
|
|
|
def test_out_of_scope_paths_are_403(self):
|
|
suffixes = ("coreci/proxmox/extra", "other/proxmox", "coreci", "CORECI/proxmox")
|
|
for suffix in suffixes:
|
|
with self.subTest(suffix=suffix):
|
|
status, body = self._secret(suffix)
|
|
self.assertEqual(403, status)
|
|
self.assertEqual(403, body["error"])
|
|
self.assertEqual(len(suffixes), len(self._read_entries("denied")))
|
|
|
|
def test_absent_secret_is_404_with_error_audit(self):
|
|
status, body = self._secret("coreci/nonexistent",
|
|
headers=self._ghost_headers("nonexistent"))
|
|
self.assertEqual(404, status)
|
|
self.assertEqual({"error": 404, "message": "not found"}, body)
|
|
entries = self._read_entries("error")
|
|
self.assertEqual(1, len(entries))
|
|
self._assert_identity(entries[0], "coreci/nonexistent", "nonexistent")
|
|
|
|
def test_traversal_payloads_never_reach_source(self):
|
|
original = self.broker.creds_source.get_secret
|
|
self.addCleanup(setattr, self.broker.creds_source, "get_secret", original)
|
|
|
|
def detonate(path):
|
|
raise AssertionError("creds source reached for %r" % path)
|
|
|
|
self.broker.creds_source.get_secret = detonate
|
|
attempts = ["%2e%2e/", "..%2f", "coreci%5cproxmox", "coreci%00proxmox",
|
|
"//coreci/proxmox", "%2e%2e/coreci/proxmox", "coreci/..%2fproxmox"]
|
|
for suffix in attempts:
|
|
with self.subTest(suffix=suffix):
|
|
status, body = self._secret(suffix)
|
|
self.assertEqual(403, status)
|
|
self.assertEqual(403, body["error"])
|
|
self.assertEqual(len(attempts), len(self._read_entries("denied")))
|
|
|
|
def test_audit_completeness_action_read_covers_all_results(self):
|
|
self._secret("coreci/proxmox")
|
|
self._secret("coreci/gitea")
|
|
self._secret("coreci/nonexistent", headers=self._ghost_headers("nonexistent"))
|
|
entries = self._audit("read")
|
|
self.assertEqual(3, len(entries))
|
|
by_result = {entry["result"]: entry for entry in entries}
|
|
self.assertEqual({"ok", "denied", "error"}, set(by_result))
|
|
self._assert_identity(by_result["ok"], "coreci/proxmox")
|
|
self._assert_identity(by_result["denied"], "coreci/gitea")
|
|
self._assert_identity(by_result["error"], "coreci/nonexistent", "nonexistent")
|
|
|
|
|
|
class SecretsSourceErrorTests(_SecretsCase):
|
|
creds_json = "{not-json"
|
|
|
|
def test_source_failure_is_500_contract_with_error_audit(self):
|
|
status, body = self._secret("coreci/proxmox")
|
|
self.assertEqual(500, status)
|
|
self.assertEqual({"error": 500, "message": "creds source unavailable"}, body)
|
|
entries = self._read_entries("error")
|
|
self.assertEqual(1, len(entries))
|
|
self._assert_identity(entries[0], "coreci/proxmox")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main() |