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---
290 lines
12 KiB
Python
290 lines
12 KiB
Python
import ast
|
|
import base64
|
|
import contextlib
|
|
import importlib.util
|
|
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"
|
|
APPS_PATH = "/api/broker/app_registrations"
|
|
USERS_PATH = "/api/broker/user_allowlist"
|
|
REGISTER_PATH = "/api/broker/agents/register"
|
|
AUDIT_PATH = "/api/broker/audit"
|
|
SECRETS_PREFIX = "/api/broker/secrets/"
|
|
CLIENT_STATIC_PATH = "/static/coreci_broker_client.py"
|
|
PROXMOX_PATH = "coreci/proxmox"
|
|
GITEA_PATH = "coreci/gitea"
|
|
PROXMOX_SECRET = {"url": "https://pve.coreci.dev:8006", "token": "pve-tok"}
|
|
GITEA_SECRET = {"url": "https://git.coreci.dev", "token": "git-tok"}
|
|
AUTH_401_BODY = {"error": 401, "message": "invalid app credentials"}
|
|
GITEA_403_BODY = {"error": 403, "message": "forbidden"}
|
|
REFERENCE_DIR = os.path.abspath(
|
|
os.path.join(os.path.dirname(__file__), os.pardir, "reference_tools")
|
|
)
|
|
STANDALONE_CLIENT_IMPORTS = {"json", "time", "urllib.parse", "urllib.request"}
|
|
|
|
|
|
def _basic_header(username, password):
|
|
raw = "%s:%s" % (username, password)
|
|
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("ascii")
|
|
|
|
|
|
def _flip(token):
|
|
replacement = "x" if token[21] != "x" else "y"
|
|
return token[:21] + replacement + token[22:]
|
|
|
|
|
|
class AcceptanceP4Tests(unittest.TestCase):
|
|
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(json.dumps(
|
|
{PROXMOX_PATH: PROXMOX_SECRET, GITEA_PATH: GITEA_SECRET}
|
|
))
|
|
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()
|
|
|
|
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, tags):
|
|
payload = {"agent_id": "proxmox", "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 _app_header(self, token):
|
|
return {"Authorization": "App %s %s" % (self.app_uuid, token)}
|
|
|
|
def _secret(self, suffix, headers):
|
|
return self._request("GET", SECRETS_PREFIX + suffix, headers=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 _fetch_client_source(self):
|
|
with urllib.request.urlopen(
|
|
self.base + CLIENT_STATIC_PATH, timeout=10
|
|
) as response:
|
|
self.assertEqual(200, response.status)
|
|
return response.read().decode("utf-8")
|
|
|
|
def _import_module(self, name, source):
|
|
path = os.path.join(self.tmp, name + ".py")
|
|
with open(path, "w", encoding="utf-8") as handle:
|
|
handle.write(source)
|
|
spec = importlib.util.spec_from_file_location(name, path)
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
def _assert_standalone_imports(self, source):
|
|
modules = set()
|
|
for node in ast.walk(ast.parse(source)):
|
|
if isinstance(node, ast.Import):
|
|
modules.update(alias.name for alias in node.names)
|
|
elif isinstance(node, ast.ImportFrom):
|
|
modules.add(node.module or "")
|
|
self.assertTrue(modules)
|
|
for module in modules:
|
|
self.assertIn(module, STANDALONE_CLIENT_IMPORTS, module)
|
|
|
|
def _bootstrapped_client(self):
|
|
module = self._import_module(
|
|
"p4_fetched_coreci_broker_client", self._fetch_client_source()
|
|
)
|
|
return module.BrokerClient(
|
|
self.base, "proxmox", self.app_uuid, self.user_id, ["coreci", "bootstrap"]
|
|
)
|
|
|
|
def _reference_tools(self):
|
|
source_path = os.path.join(REFERENCE_DIR, "proxmox_readonly_reference.py")
|
|
spec = importlib.util.spec_from_file_location(
|
|
"p4_proxmox_readonly_reference", source_path
|
|
)
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
tools = module.Tools()
|
|
tools.valves.base_url = self.base
|
|
tools.valves.agent_id = "proxmox"
|
|
tools.valves.app_uuid = self.app_uuid
|
|
tools.valves.user_id = self.user_id
|
|
return tools
|
|
|
|
def _persisted_state(self):
|
|
return self.broker.store.get_agent_state("proxmox", self.app_uuid)
|
|
|
|
def _assert_tool_identity(self, entry):
|
|
self.assertEqual(self.user_id, entry["user_id"])
|
|
self.assertIsNotNone(entry["user_id"])
|
|
self.assertEqual(self.app_uuid, entry["app_registration_uuid"])
|
|
self.assertIsNotNone(entry["app_registration_uuid"])
|
|
self.assertEqual("proxmox", entry["agent_id"])
|
|
self.assertIsNotNone(entry["agent_id"])
|
|
|
|
def test_ac5_valid_token_reads_secret_tampered_token_is_401(self):
|
|
registered = self._register(["coreci"])
|
|
status, body = self._secret(
|
|
"coreci/proxmox", self._app_header(registered["token"])
|
|
)
|
|
self.assertEqual(200, status)
|
|
self.assertEqual({"path", "data", "version", "updated_at"}, set(body))
|
|
self.assertEqual(PROXMOX_PATH, body["path"])
|
|
self.assertEqual(PROXMOX_SECRET, body["data"])
|
|
self.assertRegex(body["version"], r"^[0-9a-f]{8}$")
|
|
flipped = _flip(registered["token"])
|
|
self.assertNotEqual(registered["token"], flipped)
|
|
status, body = self._secret("coreci/proxmox", self._app_header(flipped))
|
|
self.assertEqual(401, status)
|
|
self.assertEqual(AUTH_401_BODY, body)
|
|
|
|
def test_ac6_cross_path_read_is_403_with_denied_audit(self):
|
|
registered = self._register(["coreci"])
|
|
status, body = self._secret("coreci/gitea", self._app_header(registered["token"]))
|
|
self.assertEqual(403, status)
|
|
self.assertEqual(GITEA_403_BODY, body)
|
|
denied = [
|
|
entry for entry in self._audit("read") if entry["result"] == "denied"
|
|
]
|
|
self.assertEqual(1, len(denied))
|
|
self._assert_tool_identity(denied[0])
|
|
self.assertEqual("read", denied[0]["action"])
|
|
self.assertEqual(GITEA_PATH, denied[0]["path"])
|
|
|
|
def test_client_bootstrap_public_fetch_import_register_state_secret(self):
|
|
source = self._fetch_client_source()
|
|
self.assertIn("class BrokerClient", source)
|
|
self._assert_standalone_imports(source)
|
|
client = self._bootstrapped_client()
|
|
state = {}
|
|
client.ensure_registered(state)
|
|
self.assertEqual(44, len(state["current_token"]))
|
|
self.assertEqual("proxmox", state["agent_id"])
|
|
self.assertEqual(self.app_uuid, state["app_registration_uuid"])
|
|
self.assertEqual(self.user_id, state["user_id"])
|
|
self.assertIn("pepper", state)
|
|
self.assertTrue(client.put_state(state))
|
|
document = client.read_secret(PROXMOX_PATH, state)
|
|
self.assertEqual(PROXMOX_PATH, document["path"])
|
|
self.assertEqual(PROXMOX_SECRET, document["data"])
|
|
persisted = self._persisted_state()
|
|
self.assertEqual(state["current_token"], persisted["current_token"])
|
|
|
|
def test_reference_tool_full_flow_single_register_line(self):
|
|
tools = self._reference_tools()
|
|
before = len(self._audit("register"))
|
|
response = tools.list_nodes()
|
|
self.assertIs(True, response["dry_run"])
|
|
self.assertIs(True, response["credential_present"])
|
|
registers = self._audit("register")
|
|
self.assertEqual(1, len(registers) - before)
|
|
self.assertEqual("ok", registers[-1]["result"])
|
|
self._assert_tool_identity(registers[-1])
|
|
persisted = self._persisted_state()
|
|
self.assertEqual(tools._state["current_token"], persisted["current_token"])
|
|
|
|
def test_phase_audit_completeness_after_full_drive(self):
|
|
registered = self._register(["coreci"])
|
|
token = registered["token"]
|
|
status, _ = self._secret("coreci/proxmox", self._app_header(token))
|
|
self.assertEqual(200, status)
|
|
status, _ = self._secret("coreci/proxmox", self._app_header(_flip(token)))
|
|
self.assertEqual(401, status)
|
|
status, _ = self._secret("coreci/gitea", self._app_header(token))
|
|
self.assertEqual(403, status)
|
|
client = self._bootstrapped_client()
|
|
state = {}
|
|
client.ensure_registered(state)
|
|
self.assertTrue(client.put_state(state))
|
|
self.assertEqual(PROXMOX_SECRET, client.read_secret(PROXMOX_PATH, state)["data"])
|
|
before = len(self._audit("register"))
|
|
tools = self._reference_tools()
|
|
response = tools.list_nodes()
|
|
self.assertIs(True, response["dry_run"])
|
|
self.assertIs(True, response["credential_present"])
|
|
registers = self._audit("register")
|
|
self.assertEqual(1, len(registers) - before)
|
|
self.assertEqual(3, len(registers))
|
|
for entry in registers:
|
|
self.assertEqual("ok", entry["result"])
|
|
self.assertEqual(REGISTER_PATH, entry["path"])
|
|
self._assert_tool_identity(entry)
|
|
reads = self._audit("read")
|
|
ok_reads = [entry for entry in reads if entry["result"] == "ok"]
|
|
denied_reads = [entry for entry in reads if entry["result"] == "denied"]
|
|
self.assertEqual(3, len(ok_reads))
|
|
for entry in ok_reads:
|
|
self.assertEqual(PROXMOX_PATH, entry["path"])
|
|
self._assert_tool_identity(entry)
|
|
self.assertEqual(1, len(denied_reads))
|
|
self.assertEqual(GITEA_PATH, denied_reads[0]["path"])
|
|
self._assert_tool_identity(denied_reads[0])
|
|
denied_actions = self._audit("denied")
|
|
self.assertEqual(1, len(denied_actions))
|
|
self.assertEqual("denied", denied_actions[0]["action"])
|
|
self.assertEqual("denied", denied_actions[0]["result"])
|
|
self.assertEqual(SECRETS_PREFIX + "coreci/proxmox", denied_actions[0]["path"])
|
|
self.assertEqual(self.app_uuid, denied_actions[0]["app_registration_uuid"])
|
|
self.assertIsNone(denied_actions[0]["user_id"])
|
|
self.assertIsNone(denied_actions[0]["agent_id"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main() |