4 Commits

Author SHA1 Message Date
coreci cd6892a5bd docs(P04): complete secrets-client-ecosystem phase
---ci---
phase: 4
milestone: v0.1
status: complete
requirements:
  covered: [REQ-07, REQ-08, REQ-19, REQ-20]
  partial: []
---/ci---
2026-10-10 14:41:45 +00:00
coreci 68d6ba4652 docs(P03): ship checkpoint — v0.1.3 complete
---ci---
phase: 3
milestone: v0.1
status: complete
---/ci---
2026-10-10 13:28:51 +00:00
coreci 2de11e9724 docs(P03): complete tool-auth-plane phase
---ci---
phase: 3
milestone: v0.1
status: complete
requirements:
  covered: [REQ-02, REQ-04, REQ-09, REQ-10]
  partial: []
---/ci---
2026-10-10 13:28:39 +00:00
coreci 4e326c309c docs(P02): ship checkpoint — v0.1.2 complete
---ci---
phase: 2
milestone: v0.1
status: complete
---/ci---
2026-10-10 01:16:35 +00:00
23 changed files with 4131 additions and 25 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
{
"phase": 2,
"phase": 4,
"stage": "verify",
"milestone": "v0.1",
"phase_role": "execution",
"attempts": 0,
"updated_at": "2026-10-10T01:10:00Z"
"updated_at": "2026-10-10T03:45:00Z"
}
+3 -1
View File
@@ -67,4 +67,6 @@ Known spec defects resolved by clarification (recorded for traceability): §7.5'
| D-013 | Session cookie `broker_session` uses HttpOnly + SameSite=Lax | Spec silent on cookie attributes; HttpOnly blocks JS exfiltration, Lax preserves bookmark-login UX | Binding for v0.1 |
| D-014 | `BROKER_CREDS_FILE` defaults to `data/creds.json` | FileCredsSource dev/test default location; `data/` already gitignored | Binding for v0.1 |
| D-015 | `GET /api/broker/agents/<agent_id>`: App-auth + identity match, metadata-only response (no token, no hash) | §7.4 auth/response unspecified; mirrors state-endpoint identity enforcement; token material never leaves the broker | Binding for v0.1 |
| D-016 | AC14 line cap amended: coreci_broker.py < 1000 lines (was < 800) | Owner-approved §12.14 amendment at P2: 11 lines of headroom vs ~275 lines of remaining handler surface; preserves the readable single-file intent without dense-cramming | Binding for v0.1 |
| D-016 | AC14 line cap amended: coreci_broker.py < 1000 lines (was < 800) | Owner-approved §12.14 amendment at P2: 11 lines of headroom vs ~275 lines of remaining handler surface; preserves the readable single-file intent without dense-cramming | Binding for v0.1 |
| D-017 | D-016 extended: AC14 cap < 1200 lines | Second §12.14 amendment at P3 (owner's D-016 pattern applied with new data): P3-01 lands at 990/1000 with ~150-180 lines of remaining broker surface (App-auth cache, agent state, creds adapter, secrets endpoint); mild compaction reclaims only ~50 — same readability-over-cramming tradeoff the owner chose at D-016; subject to review at the P5 owner approval gate | Binding for v0.1 |
| D-018 | D-017 extended: AC14 cap < 1400 lines — final projected amendment | Third §12.14 amendment, pre-P4 per the P3 verify planning signal: 1178/1200 after P3 with 21 lines headroom vs P4's ~105-140 line need (creds adapter + secrets endpoint, the LAST broker-surface additions); P5 adds only excluded artifacts (tests, README). Same owner pattern (D-016/D-017), flagged for the P5 owner approval gate | Binding for v0.1 |
+9 -9
View File
@@ -42,7 +42,7 @@ Source: Requirements Specification v0.5 (owner: coreci-coo, 2026-10-09; supersed
- **REQ-14 `.env` config + first boot.** The broker reads its `.env` at `BROKER_ENV_PATH` on boot (spec §5.1 contract plus the documented additions). On first boot it generates `BROKER_INSTANCE_PEPPER` (32 random bytes, base64 — synced to SQLite) and `BROKER_ADMIN_PASS` (32+ random chars), writes them to `.env`, and prints the admin pass to the startup log exactly once — never again.
- **REQ-15 Mode 0600.** `broker.db` and `broker.audit.log` are created mode 0600, owned by the Open WebUI process user. `.env.secrets` discipline (ciagent) mirrors this.
- **REQ-16 Persistence across restarts.** SQLite and the audit file are the durable state. Restarts preserve registrations, allowlist entries, sessions' data, and audit history; in-memory caches (verified-token cache) rebuild lazily; existing tokens still verify.
- **REQ-17 Single-file constraint.** `coreci_broker.py` is one Python file under **1000** lines (amended from 800 by owner decision D-016, P2 2026-10-10), excluding tests, UI HTML, the client module, and reference tools. A line-count test enforces it (AC14).
- **REQ-17 Single-file constraint.** `coreci_broker.py` is one Python file under **1400** lines (amended 800→1000 D-016, →1200 D-017, →1400 D-018 — owner-approved §12.14 amendment pattern, final projected), excluding tests, UI HTML, the client module, and reference tools. A line-count test enforces it (AC14).
- **REQ-18 Runtime modes.** Importable as an Open WebUI workspace tool (daemon-thread server + a `Tools` class surface) and runnable standalone (`python3 coreci_broker.py`). Stdlib-only at runtime; bcrypt is the sole optional import (D-001/D-009). Tests use unittest against an in-process server on an ephemeral port.
### Client Ecosystem
@@ -78,15 +78,15 @@ None defined. Future candidates held as out-of-scope decisions, not requirements
| Requirement | Phase | Status |
|-------------|-------|--------|
| REQ-01 | 2 | complete |
| REQ-02 | 3 | pending |
| REQ-02 | 3 | complete |
| REQ-03 | 1 | complete |
| REQ-04 | 3 | pending |
| REQ-04 | 3 | complete |
| REQ-05 | 2 | complete |
| REQ-06 | 2 | complete |
| REQ-07 | 4 | pending |
| REQ-08 | 4 | pending |
| REQ-09 | 3 | pending |
| REQ-10 | 3 | pending |
| REQ-07 | 4 | complete |
| REQ-08 | 4 | complete |
| REQ-09 | 3 | complete |
| REQ-10 | 3 | complete |
| REQ-11 | 1 | complete |
| REQ-12 | 2 | complete |
| REQ-13 | 2 | complete |
@@ -95,5 +95,5 @@ None defined. Future candidates held as out-of-scope decisions, not requirements
| REQ-16 | 1 | complete |
| REQ-17 | 1 | complete |
| REQ-18 | 1 | complete |
| REQ-19 | 4 | pending |
| REQ-20 | 4 | pending |
| REQ-19 | 4 | complete |
| REQ-20 | 4 | complete |
+2 -2
View File
@@ -38,7 +38,7 @@ Autonomy: **guided** — escalate on every decision gate; threshold 0.85; 1 revi
- **Requirements covered**: REQ-02, REQ-04, REQ-09, REQ-10
- **Success criteria**: AC4 (token derived + stored as slow-hash, same value computed both sides), AC7 (revoked/unknown UUID → 403), AC8 (unknown user → 403), AC10 (TTL expiry → 401 → re-register → new token, audited), AC12 (state read/write with identity enforcement)
- **Depends on**: 2
- **Status**: not_started
- **Status**: complete
## Phase 4 — Secrets + Client Ecosystem
@@ -46,7 +46,7 @@ Autonomy: **guided** — escalate on every decision gate; threshold 0.85; 1 revi
- **Requirements covered**: REQ-07, REQ-08, REQ-19, REQ-20
- **Success criteria**: AC5 (valid token reads the secret; tampered token → 401), AC6 (proxmox-registered tool reading `coreci/gitea` → 403); client module fetchable unauthenticated and drives a reference tool end-to-end against a test broker
- **Depends on**: 3
- **Status**: not_started
- **Status**: complete
## Phase 5 — Final Review + Ship
+489 -9
View File
@@ -16,7 +16,9 @@ import secrets
import sqlite3
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -39,6 +41,12 @@ ENV_KEYS = (
"BROKER_AUDIT_LOG_PATH",
"BROKER_UI_ROOT",
"BROKER_STATIC_ROOT",
"BROKER_AGENT_STATE_BACKEND",
"BROKER_CREDS_SOURCE",
"BROKER_CREDS_FILE",
"BROKER_OWUI_BASE_URL",
"BROKER_OWUI_API_KEY",
"BROKER_MAX_AGENTS_PER_APP",
)
ENV_DEFAULTS = {
@@ -50,6 +58,10 @@ ENV_DEFAULTS = {
"BROKER_AUDIT_LOG_PATH": "broker.audit.log",
"BROKER_UI_ROOT": "coreci_broker_ui",
"BROKER_STATIC_ROOT": ".",
"BROKER_AGENT_STATE_BACKEND": "internal",
"BROKER_CREDS_SOURCE": "file",
"BROKER_CREDS_FILE": "data/creds.json",
"BROKER_MAX_AGENTS_PER_APP": "50",
}
AUDIT_ACTIONS = (
@@ -89,6 +101,8 @@ STATIC_CONTENT_TYPES = {
MAX_BODY_BYTES = 1024 * 1024
MAX_STATE_BYTES = 256 * 1024
SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS broker_config (
key TEXT PRIMARY KEY,
@@ -126,6 +140,13 @@ CREATE TABLE IF NOT EXISTS admin_sessions (
created_at TEXT,
expires_at TEXT
);
CREATE TABLE IF NOT EXISTS agent_state (
agent_id TEXT,
app_registration_uuid TEXT,
state TEXT,
updated_at TEXT,
PRIMARY KEY (agent_id, app_registration_uuid)
);
"""
@@ -299,11 +320,211 @@ class Store:
(status, _utc_now_iso() if status == "revoked" else None, value),
)
def get_agent(self, agent_id, app_uuid):
rows = self._run(
"SELECT * FROM agent_registrations WHERE agent_id = ? AND app_registration_uuid = ?",
(agent_id, app_uuid),
)
return rows[0] if rows else None
def agents_by_uuid(self, app_uuid):
return self._run(
"SELECT * FROM agent_registrations WHERE app_registration_uuid = ?", (app_uuid,)
)
def save_agent(self, agent_id, app_uuid, user_id, tags, token_hash, expires_at, now):
self._run(
"INSERT INTO agent_registrations VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(agent_id, app_uuid, user_id, tags, token_hash, expires_at, now, now),
)
def update_agent(self, agent_id, app_uuid, user_id, tags, token_hash, expires_at, now):
self._run(
"UPDATE agent_registrations SET user_id = ?, tags = ?, token_hash = ?, "
"token_expires_at = ?, last_seen_at = ? WHERE agent_id = ? AND app_registration_uuid = ?",
(user_id, tags, token_hash, expires_at, now, agent_id, app_uuid),
)
def touch_agent(self, agent_id, app_uuid, now):
self._run(
"UPDATE agent_registrations SET last_seen_at = ? WHERE agent_id = ? AND app_registration_uuid = ?",
(now, agent_id, app_uuid),
)
def get_agent_state(self, agent_id, app_uuid):
rows = self._run("SELECT state FROM agent_state WHERE agent_id = ? AND app_registration_uuid = ?",
(agent_id, app_uuid))
return json.loads(rows[0][0]) if rows else None
def save_agent_state(self, agent_id, app_uuid, state):
self._run("INSERT OR REPLACE INTO agent_state VALUES (?, ?, ?, ?)",
(agent_id, app_uuid, state, _utc_now_iso()))
def close(self):
with self._lock:
self.conn.close()
class InternalAgentStateBackend:
def __init__(self, store):
self.store = store
def get_state(self, agent_id, app_uuid):
return self.store.get_agent_state(agent_id, app_uuid)
def put_state(self, agent_id, app_uuid, state):
self.store.save_agent_state(agent_id, app_uuid, json.dumps(state))
class OwuiAgentStateBackend:
"""Agent-state documents over HTTP: GET/PUT {base}/api/v1/broker-agent-state/{agent_id}."""
def __init__(self, base_url, api_key):
self.base_url = str(base_url or "").rstrip("/")
self.api_key = str(api_key or "")
def _request(self, method, agent_id, payload=None):
url = self.base_url + "/api/v1/broker-agent-state/" + urllib.parse.quote(agent_id, safe="")
data = None if payload is None else json.dumps(payload).encode("utf-8")
request = urllib.request.Request(
url, data=data, method=method,
headers={"Authorization": "Bearer %s" % self.api_key, "Content-Type": "application/json"},
)
with urllib.request.urlopen(request, timeout=30) as response:
return response.read()
def get_state(self, agent_id, app_uuid):
try:
raw = self._request("GET", agent_id)
except urllib.error.HTTPError as error:
if error.code == 404:
return None
raise
return json.loads(raw.decode("utf-8")) if raw else None
def put_state(self, agent_id, app_uuid, state):
self._request("PUT", agent_id, state)
def _build_agent_state_backend(config, store):
if str(config.get("BROKER_AGENT_STATE_BACKEND") or "") == "owui":
return OwuiAgentStateBackend(
config.get("BROKER_OWUI_BASE_URL"), config.get("BROKER_OWUI_API_KEY")
)
return InternalAgentStateBackend(store)
class _SecretNotFound(Exception):
pass
class _CredsSourceError(Exception):
pass
def _secret_response(path, data, updated_at=None):
serialized = json.dumps(data, sort_keys=True, separators=(",", ":")).encode("utf-8")
return {"path": path, "data": data, "version": hashlib.sha256(serialized).hexdigest()[:8],
"updated_at": updated_at}
class FileCredsSource:
def __init__(self, path):
self.path = str(path or "data/creds.json")
def get_secret(self, path):
try:
with open(self.path, "r", encoding="utf-8") as handle:
document = json.load(handle)
except FileNotFoundError:
raise _SecretNotFound(path)
except (OSError, ValueError) as error:
raise _CredsSourceError(f"creds file unusable: {error}")
if not isinstance(document, dict):
raise _CredsSourceError("creds file must map paths to secret objects")
if path not in document:
raise _SecretNotFound(path)
return _secret_response(path, document[path])
class OwuiApiCredsSource:
"""Secrets from the `creds-coreci-coo` KB via the OWUI HTTP API; a KB file's
name is its secret path (e.g. `coreci/proxmox`), its content the secret JSON."""
KB_NAME = "creds-coreci-coo"
def __init__(self, base_url, api_key):
self.base_url = str(base_url or "").rstrip("/")
self.api_key = str(api_key or "")
self._file_map = None
def _fetch_json(self, url_path):
try:
url = self.base_url + url_path
request = urllib.request.Request(url, headers={"Authorization": "Bearer %s" % self.api_key})
with urllib.request.urlopen(request, timeout=30) as response:
raw = response.read()
except urllib.error.HTTPError as error:
if error.code == 404:
raise _SecretNotFound(url_path)
raise _CredsSourceError(f"creds source returned HTTP {error.code} for {url_path}")
except (OSError, ValueError) as error:
raise _CredsSourceError(f"creds source unavailable: {error}")
try:
body = json.loads(raw.decode("utf-8"))
except ValueError:
raise _CredsSourceError(f"creds source returned invalid JSON for {url_path}")
if not isinstance(body, dict):
raise _CredsSourceError(f"creds source returned a non-object for {url_path}")
return body
def _kb_id(self):
for page in range(1, 1001):
body = self._fetch_json("/api/v1/knowledge/?page=%d" % page)
items = body.get("items") or []
for item in items:
if item.get("name") == self.KB_NAME:
return item.get("id")
total = body.get("total")
if not items or (isinstance(total, int) and page * 30 >= total):
raise _SecretNotFound(self.KB_NAME)
raise _CredsSourceError("creds source pagination did not terminate")
def _load_file_map(self):
kb_id = self._kb_id()
body = self._fetch_json("/api/v1/knowledge/%s" % urllib.parse.quote(str(kb_id), safe=""))
file_map = {}
for meta in body.get("files") or []:
name, file_id = meta.get("name"), meta.get("id")
if isinstance(name, str) and name and file_id is not None:
file_map[name] = (str(file_id), meta.get("updated_at"))
return file_map
def get_secret(self, path):
if self._file_map is None or path not in self._file_map:
self._file_map = self._load_file_map()
if path not in self._file_map:
raise _SecretNotFound(path)
file_id, updated_at = self._file_map[path]
body = self._fetch_json("/api/v1/files/%s/data/content" % urllib.parse.quote(file_id, safe=""))
content = body.get("content")
if not isinstance(content, str):
raise _CredsSourceError(f"creds file for {path} returned no content")
try:
data = json.loads(content)
except ValueError:
raise _CredsSourceError(f"creds file for {path} is invalid JSON")
return _secret_response(path, data, updated_at)
def _build_creds_source(config):
source = str(config.get("BROKER_CREDS_SOURCE") or "file")
if source == "file":
return FileCredsSource(config.get("BROKER_CREDS_FILE"))
if source == "owui":
return OwuiApiCredsSource(config.get("BROKER_OWUI_BASE_URL"), config.get("BROKER_OWUI_API_KEY"))
raise ValueError(f"BROKER_CREDS_SOURCE must be file or owui, got {source!r}")
class Audit:
def __init__(self, path):
self.path = path
@@ -344,6 +565,10 @@ class Broker:
self.config, self.store, self.audit = config, store, audit
self.ui_root = config.get("BROKER_UI_ROOT") or "coreci_broker_ui"
self.static_root = config.get("BROKER_STATIC_ROOT") or "."
self.token_cache = {}
self.token_cache_lock = threading.Lock()
self.agent_state_backend = _build_agent_state_backend(config, store)
self.creds_source = _build_creds_source(config)
self.router = Router()
self.router.add("GET", "/healthz", _healthz)
self.router.add("POST", "/api/broker/auth/login", _auth_login)
@@ -358,6 +583,15 @@ class Broker:
self.router.add("DELETE", "/api/broker/user_allowlist/<user_id>", _users_revoke)
self.router.add("POST", "/api/broker/user_allowlist/<user_id>/reinstate", _users_reinstate)
self.router.add("GET", "/api/broker/audit", _audit_query)
self.router.add("POST", "/api/broker/agents/register", _agents_register)
self.router.add("GET", "/api/broker/agents/<agent_id>", _require_app(_agent_meta))
self.router.add("GET", "/api/broker/agents/<agent_id>/state", _require_app(_agent_state_get))
self.router.add("PUT", "/api/broker/agents/<agent_id>/state", _require_app(_agent_state_put))
self.router.add("GET", "/api/broker/secrets/<path...>", _require_app(_secrets_get))
def clear_token_cache(self):
with self.token_cache_lock:
self.token_cache.clear()
def boot(env=None):
@@ -388,6 +622,8 @@ def _check_admin_credentials(broker, username, password):
def _basic_credentials(request):
header = request.headers.get("Authorization") or ""
if header[:6].lower() != "basic ":
return "", ""
try:
decoded = base64.b64decode(header[6:].strip(), validate=True).decode("utf-8")
except ValueError:
@@ -492,6 +728,15 @@ def _required_string(value):
return value if isinstance(value, str) and value else None
def _valid_user_id(value):
if not _required_string(value):
return None
if (":" in value or "/" in value or "\\" in value
or any(ord(ch) < 32 or ord(ch) == 127 for ch in value)):
return None
return value
def _add_row(request, table, key, field, action, validate):
if not _require_admin(request):
return
@@ -515,7 +760,7 @@ def _apps_add(request):
def _users_add(request):
_add_row(request, "user_allowlist", "user_id", "email", "add_user", _required_string)
_add_row(request, "user_allowlist", "user_id", "email", "add_user", _valid_user_id)
def _set_row_status(request, table, key, status, action):
@@ -549,6 +794,230 @@ def _users_reinstate(request):
_set_row_status(request, "user_allowlist", "user_id", "active", "add_user")
def _parse_app_header(header):
parts = (header or "").split()
if len(parts) != 3 or parts[0] != "App":
return None, None
app_uuid = _canonical_uuid(parts[1])
return (app_uuid, parts[2]) if app_uuid is not None else (None, None)
def _app_auth_context(row):
return {
"agent_id": row[0],
"user_id": row[2],
"app_registration_uuid": row[1],
"token_expires_at": row[5],
}
def _verify_app_token(broker, header):
app_uuid, token = _parse_app_header(header)
if app_uuid is None:
return None
now = _utc_now_iso()
key = (app_uuid, token)
with broker.token_cache_lock:
cached = broker.token_cache.get(key)
if cached is not None:
row = broker.store.get_agent(cached["agent_id"], app_uuid)
if row is not None and row[5] and row[5] > now:
return _app_auth_context(row)
with broker.token_cache_lock:
broker.token_cache.pop(key, None)
return None
for row in broker.store.agents_by_uuid(app_uuid):
if row[5] and row[5] > now and verify_token(token, row[4]):
context = _app_auth_context(row)
with broker.token_cache_lock:
broker.token_cache[key] = context
return context
return None
def _app_token_valid(broker, header):
return _verify_app_token(broker, header) is not None
def _audit_app_denied(request):
parts = (request.headers.get("Authorization") or "").split()
if len(parts) < 2 or parts[0] != "App":
return
request.broker.audit.write(
"denied", path=request.path, result="denied",
app_registration_uuid=_canonical_uuid(parts[1]),
)
def _require_app(handler):
def wrapped(request):
context = _verify_app_token(request.broker, request.headers.get("Authorization"))
if context is None:
_audit_app_denied(request)
request.send_error_json(401, "invalid app credentials")
return
request.app_auth = context
request.broker.store.touch_agent(
context["agent_id"], context["app_registration_uuid"], _utc_now_iso()
)
handler(request)
return wrapped
def _agents_register(request):
broker = request.broker
body = request.body if isinstance(request.body, dict) else {}
agent_id = body.get("agent_id")
app_uuid = body.get("app_registration_uuid")
user_id = body.get("user_id")
tags = body.get("tags")
strings = [v for v in (agent_id, app_uuid, user_id) if isinstance(v, str) and v]
if len(strings) != 3 or not isinstance(tags, list) or not all(isinstance(v, str) for v in tags):
request.send_error_json(400, "invalid registration")
return
if ":" in agent_id or "/" in agent_id or "\\" in agent_id:
request.send_error_json(400, "invalid agent_id")
return
if any(":" in tag for tag in tags):
request.send_error_json(400, "invalid tags")
return
path = "/api/broker/agents/register"
identity = {"user_id": user_id, "app_registration_uuid": app_uuid, "agent_id": agent_id}
app_row = broker.store.get_row("app_registrations", "uuid", app_uuid)
user_row = broker.store.get_row("user_allowlist", "user_id", user_id)
if app_row is None or app_row[2] != "active" or user_row is None or user_row[2] != "active":
broker.audit.write("register", path=path, result="denied", **identity)
request.send_error_json(403, "registration not allowed")
return
max_raw = str(broker.config.get("BROKER_MAX_AGENTS_PER_APP") or "")
max_agents = int(max_raw) if max_raw.isdigit() else 50
if len(broker.store.agents_by_uuid(app_uuid)) >= max_agents:
request.send_error_json(400, "too many agents for this app")
return
pepper = broker.store.get_config("BROKER_INSTANCE_PEPPER") or broker.config["BROKER_INSTANCE_PEPPER"]
token = derive_token(pepper, app_uuid, user_id, agent_id, tags)
raw_ttl = str(broker.config.get("BROKER_TOKEN_TTL") or "")
ttl = int(raw_ttl) if raw_ttl.isdigit() else 86400
now = datetime.datetime.now(datetime.timezone.utc)
expires = (now + datetime.timedelta(seconds=ttl)).isoformat()
tags_json = json.dumps(sorted(tags))
row = broker.store.get_agent(agent_id, app_uuid)
created = row is None
if created:
try:
broker.store.save_agent(agent_id, app_uuid, user_id, tags_json, hash_token(token), expires, now.isoformat())
except sqlite3.IntegrityError:
row = broker.store.get_agent(agent_id, app_uuid)
if row is None:
raise
created = False
if not created:
if row[2] == user_id and row[3] == tags_json and row[5] and row[5] > now.isoformat():
expires = row[5]
broker.store.touch_agent(agent_id, app_uuid, now.isoformat())
else:
broker.store.update_agent(agent_id, app_uuid, user_id, tags_json, hash_token(token), expires, now.isoformat())
with broker.token_cache_lock:
for cached_key in [k for k in broker.token_cache if k[0] == app_uuid]:
broker.token_cache.pop(cached_key, None)
payload = {"token": token, "token_expires_at": expires, "created": created}
if created or not _app_token_valid(broker, request.headers.get("Authorization")):
payload["pepper"] = pepper
broker.audit.write("register", path=path, **identity)
request.send_json(201 if created else 200, payload)
def _agent_identity_row(request):
auth = request.app_auth
row = request.broker.store.get_agent(
request.path_params["agent_id"], auth["app_registration_uuid"]
)
if row is None:
request.send_error_json(404, "not found")
return None
if row[0] != auth["agent_id"] or row[2] != auth["user_id"]:
request.send_error_json(403, "agent identity mismatch")
return None
return row
def _agent_meta(request):
row = _agent_identity_row(request)
if row is None:
return
request.send_json(200, {
"agent_id": row[0],
"app_registration_uuid": row[1],
"user_id": row[2],
"tags": json.loads(row[3]),
"token_expires_at": row[5],
"first_registered_at": row[6],
"last_seen_at": row[7],
})
def _agent_state_get(request):
row = _agent_identity_row(request)
if row is None:
return
state = request.broker.agent_state_backend.get_state(row[0], row[1])
if state is None:
request.send_error_json(404, "not found")
return
request.send_json(200, state)
def _agent_state_put(request):
row = _agent_identity_row(request)
if row is None:
return
body = request.body
if not isinstance(body, dict):
request.send_error_json(400, "state must be a JSON object")
return
if len(json.dumps(body).encode("utf-8")) > MAX_STATE_BYTES:
request.send_error_json(400, "state too large")
return
request.broker.agent_state_backend.put_state(row[0], row[1], body)
request.send_json(200, {"ok": True})
def _secret_path_unsafe(path):
return (path.startswith("/") or "\\" in path or "\x00" in path
or ".." in path.split("/"))
def _secrets_denied(request, path, identity):
request.broker.audit.write("read", path=path, result="denied", **identity)
request.send_error_json(403, "forbidden")
def _secrets_get(request):
broker = request.broker
auth = request.app_auth
path = urllib.parse.unquote(request.path_params["path"])
identity = {"user_id": auth["user_id"],
"app_registration_uuid": auth["app_registration_uuid"],
"agent_id": auth["agent_id"]}
if _secret_path_unsafe(path):
_secrets_denied(request, path, identity)
return
if path != "coreci/" + auth["agent_id"]:
_secrets_denied(request, path, identity)
return
try:
secret = broker.creds_source.get_secret(path)
except _SecretNotFound:
broker.audit.write("read", path=path, result="error", **identity)
request.send_error_json(404, "not found")
except _CredsSourceError:
broker.audit.write("read", path=path, result="error", **identity)
request.send_error_json(500, "creds source unavailable")
else:
broker.audit.write("read", path=path, **identity)
request.send_json(200, secret)
def _parse_iso_ts(value):
try:
parsed = datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
@@ -624,16 +1093,25 @@ def _parse_query(query):
def _match_pattern(pattern, path):
pattern_parts = pattern.split("/")
path_parts = path.split("/")
if len(pattern_parts) != len(path_parts):
if len(pattern_parts) > len(path_parts):
return None
params = {}
for expected, actual in zip(pattern_parts, path_parts):
if len(expected) > 2 and expected.startswith("<") and expected.endswith(">"):
if not actual:
for index, expected in enumerate(pattern_parts):
if not (len(expected) > 2 and expected.startswith("<") and expected.endswith(">")):
if expected != path_parts[index]:
return None
params[expected[1:-1]] = actual
elif expected != actual:
continue
name = expected[1:-1]
if name.endswith("..."):
if index + 1 != len(pattern_parts) or not path_parts[index:]:
return None
params[name[:-3]] = "/".join(path_parts[index:])
return params
if not path_parts[index]:
return None
params[name] = path_parts[index]
if len(pattern_parts) != len(path_parts):
return None
return params
@@ -644,7 +1122,7 @@ def _static_content_type(name):
def _broker_protected_paths(broker):
protected = set()
for key in ("BROKER_ENV_PATH", "BROKER_DB_PATH", "BROKER_AUDIT_LOG_PATH"):
for key in ("BROKER_ENV_PATH", "BROKER_DB_PATH", "BROKER_AUDIT_LOG_PATH", "BROKER_CREDS_FILE"):
value = broker.config.get(key) or ""
if not value:
continue
@@ -777,7 +1255,7 @@ class BrokerRequestHandler(BaseHTTPRequestHandler):
if raw:
try:
self.body = json.loads(raw)
except ValueError:
except (ValueError, RecursionError):
self.send_error_json(400, "invalid JSON body")
return
self.path_params = params
@@ -853,6 +1331,7 @@ def _reset_server_guard():
return None
server.shutdown()
server.server_close()
server.broker.clear_token_cache()
server.broker.audit.close()
server.broker.store.close()
return server
@@ -886,6 +1365,7 @@ def run_standalone(env=None):
finally:
server.shutdown()
server.server_close()
broker.clear_token_cache()
broker.audit.close()
broker.store.close()
print("coreci-broker shut down", flush=True)
+161
View File
@@ -0,0 +1,161 @@
"""Public stdlib-only client for the CoreCI Broker: idempotent tool registration, agent-state persistence, and path-scoped secret reads."""
import json
import time
import urllib.parse
import urllib.request
REGISTER_PATH = "/api/broker/agents/register"
SECRETS_PREFIX = "/api/broker/secrets/"
CLIENT_MODULE_PATH = "/static/coreci_broker_client.py"
class BrokerError(Exception):
def __init__(self, status, message):
super().__init__(message)
self.status = status
self.message = message
def _broker_error(status, body):
message = "broker returned HTTP %s" % status
if isinstance(body, dict) and body.get("message"):
message = str(body["message"])
return BrokerError(status, message)
def _epoch_from_iso(value):
try:
year, month, day = int(value[0:4]), int(value[5:7]), int(value[8:10])
hour, minute, second = int(value[11:13]), int(value[14:16]), int(value[17:19])
rest = value[19:]
micro = 0
if rest[:1] == ".":
digits = ""
for char in rest[1:]:
if not char.isdigit():
break
digits += char
micro = int((digits + "000000")[:6])
rest = rest[1 + len(digits):]
offset = 0
if rest[:1] in ("+", "-"):
sign = 1 if rest[0] == "+" else -1
offset = sign * (int(rest[1:3]) * 3600 + int(rest[4:6]) * 60)
if not 1 <= month <= 12 or not 1 <= day <= 31:
return None
if hour > 23 or minute > 59 or second > 59:
return None
year -= month <= 2
era = (year if year >= 0 else year - 399) // 400
yoe = year - era * 400
doy = (153 * (month - 3 if month > 2 else month + 9) + 2) // 5 + day - 1
doe = yoe * 365 + yoe // 4 - yoe // 100 + doy
return (era * 146097 + doe - 719468) * 86400 + hour * 3600 + minute * 60 + second - offset + micro / 1000000.0
except (ValueError, TypeError, IndexError):
return None
def _token_live(state):
token = state.get("current_token")
expires = _epoch_from_iso(state.get("token_expires_at"))
return isinstance(token, str) and bool(token) and expires is not None and expires > time.time()
class BrokerClient:
def __init__(self, base_url, agent_id, app_uuid, user_id, tags=None, timeout=10.0):
self.base_url = str(base_url).rstrip("/")
self.agent_id = agent_id
self.app_uuid = app_uuid
self.user_id = user_id
self.tags = list(tags or [])
self.timeout = timeout
def _app_headers(self, state=None):
token = (state or {}).get("current_token")
if not isinstance(token, str) or not token:
return {}
return {"Authorization": "App %s %s" % (self.app_uuid, 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_url + path, data=data, method=method)
if data is not None:
request.add_header("Content-Type", "application/json")
for key, value in (headers or {}).items():
request.add_header(key, value)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
status, raw = response.status, response.read()
except urllib.request.HTTPError as error:
status, raw = error.code, error.read()
body = None
text = raw.decode("utf-8", "replace")
if text:
try:
body = json.loads(text)
except ValueError:
body = None
return status, body
def _authorized_request(self, method, path, state, payload=None):
status, body = self._request(method, path, payload, self._app_headers(state))
if status == 401:
self.register(state)
status, body = self._request(method, path, payload, self._app_headers(state))
return status, body
def _state_path(self):
return "/api/broker/agents/%s/state" % urllib.parse.quote(self.agent_id, safe="")
def register(self, state):
if state is None:
state = {}
payload = {"agent_id": self.agent_id, "app_registration_uuid": self.app_uuid,
"user_id": self.user_id, "tags": list(self.tags)}
status, data = self._request("POST", REGISTER_PATH, payload, self._app_headers(state))
if status not in (200, 201) or not isinstance(data, dict):
raise _broker_error(status, data)
state["agent_id"] = self.agent_id
state["app_registration_uuid"] = self.app_uuid
state["user_id"] = self.user_id
state["tags"] = list(self.tags)
state["current_token"] = data["token"]
state["token_expires_at"] = data["token_expires_at"]
pepper = data.get("pepper")
if pepper is not None:
state["pepper"] = pepper
return state
def ensure_registered(self, state):
if state is None:
state = {}
if _token_live(state):
return state
return self.register(state)
def get_state(self, state):
status, body = self._authorized_request("GET", self._state_path(), state)
if status == 200:
return body
if status == 404:
return None
raise _broker_error(status, body)
def put_state(self, state):
status, body = self._authorized_request("PUT", self._state_path(), state, state)
if status == 200:
return True
raise _broker_error(status, body)
def read_secret(self, path, state):
suffix = urllib.parse.quote(str(path), safe="/")
status, body = self._authorized_request("GET", SECRETS_PREFIX + suffix, state)
if status == 200:
return body
raise _broker_error(status, body)
def fetch_client_module(base_url, timeout=10.0):
url = str(base_url).rstrip("/") + CLIENT_MODULE_PATH
with urllib.request.urlopen(url, timeout=timeout) as response:
return response.read().decode("utf-8")
+25
View File
@@ -0,0 +1,25 @@
# Reference Tools
Templates for Open WebUI tool authors wiring tools to the CoreCI Broker.
They contain no secrets and no live-infrastructure client.
- `proxmox_readonly_reference.py` - the full broker flow (spec section 11):
first call registers and persists agent state, then fetches
`coreci/proxmox`; later calls load the persisted token via `get_state`.
- `minimal_auth_showcase.py` - the minimal pattern: authenticate with the
broker, then use a second tool.
The flow both templates follow:
1. The CEO allowlists the tool's app UUID and user id in the broker UI.
2. Fetch the client from `/static/coreci_broker_client.py` and import it.
3. `ensure_registered(state)` registers (idempotent) and merges the token
into your state dict; `put_state(state)` persists it as the tool's
only durable state channel.
4. `read_secret("coreci/<agent_id>", state)` fetches the credential; on a
401 the client re-registers and retries once.
Downstream calls default to a safe dry-run with no live credential use;
replace the dry-run block with real calls in your own tool.
Client module: `coreci_broker_client.py` (stdlib urllib only, zero deps).
+45
View File
@@ -0,0 +1,45 @@
"""title: Minimal Auth Showcase [CoreCI]
author: coreci
description: Minimal Open WebUI tool template showing the CoreCI Broker pattern - authenticate with the broker first, then use a second tool.
version: 0.1.0
requirements: []
licence: MIT
"""
import coreci_broker_client
class Tools:
class Valves:
base_url = ""
agent_id = ""
app_uuid = ""
user_id = ""
def __init__(self):
self.valves = self.Valves()
self._state = {}
def _client(self):
return coreci_broker_client.BrokerClient(
self.valves.base_url, self.valves.agent_id,
self.valves.app_uuid, self.valves.user_id, ["coreci"],
)
def demo(self, context=None) -> dict:
"""Demonstrate broker auth before any downstream tool call.
Ensures the agent is registered with the broker (idempotent), then
reads the agent's own secret through the client: the credential
fetch itself proves the auth path works before any second tool
would use the credential. The secret material never appears in the
returned marker.
:param context: optional Open WebUI call context, unused
:return: marker dict with registered and secret_read flags
"""
client = self._client()
client.ensure_registered(self._state)
client.read_secret("coreci/%s" % self.valves.agent_id, self._state)
return {"registered": True, "secret_read": True,
"path": "coreci/%s" % self.valves.agent_id,
"note": "auth first, then use"}
@@ -0,0 +1,104 @@
"""title: Proxmox Read-Only Reference [CoreCI]
author: coreci
description: Open WebUI tool template for the CoreCI Broker flow (spec section 11) - register, persist agent state, read coreci/proxmox, then act; downstream Proxmox calls default to a safe dry-run.
version: 0.1.0
requirements: []
licence: MIT
"""
import datetime
import coreci_broker_client
SECRET_PATH = "coreci/proxmox"
DRY_RUN_NOTE = (
"reference dry-run: the broker credential was fetched but stays unused; "
"replace this block with a real read-only Proxmox call to go live"
)
class Tools:
class Valves:
base_url = ""
agent_id = ""
app_uuid = ""
user_id = ""
def __init__(self):
self.valves = self.Valves()
self._state = {}
def _client(self):
return coreci_broker_client.BrokerClient(
self.valves.base_url, self.valves.agent_id,
self.valves.app_uuid, self.valves.user_id, ["proxmox", "readonly"],
)
def _persist(self, client):
now = datetime.datetime.now(datetime.timezone.utc).isoformat()
self._state.setdefault("first_registered_at", now)
self._state["last_seen_at"] = now
client.put_state(self._state)
def _load_persisted(self, client):
document = client.get_state(self._state)
if document is not None:
for key in document:
self._state.setdefault(key, document[key])
def _credential(self):
client = self._client()
first_call = self._state.get("current_token") is None
fingerprint = (self._state.get("current_token"),
self._state.get("token_expires_at"))
if first_call:
client.ensure_registered(self._state)
self._persist(client)
else:
self._load_persisted(client)
document = client.read_secret(SECRET_PATH, self._state)
current = (self._state.get("current_token"),
self._state.get("token_expires_at"))
if not first_call and current != fingerprint:
self._persist(client)
return document
def _dry_run(self, action, document):
return {
"dry_run": True,
"action": action,
"credential_present": isinstance(document, dict) and "data" in document,
"note": DRY_RUN_NOTE,
}
def list_nodes(self, context=None) -> dict:
"""List the Proxmox cluster nodes as a safe dry-run by default.
First call registers with the broker (idempotent) and persists the
agent state; subsequent calls load the persisted token and reuse
it. The credential is fetched from coreci/proxmox before any
downstream action, and on a 401 the client re-registers with a
single retry after which the new token is persisted again. The
reference template then returns a dry-run marker and performs no
live Proxmox call: replace the dry-run block with a real read-only
API call when wiring live infrastructure; this template ships no
live-credential use path.
:param context: optional Open WebUI call context, unused
:return: dry-run response dict with credential presence
"""
return self._dry_run("list_nodes", self._credential())
def vm_status(self, vm_id: str, context=None) -> dict:
"""Report one Proxmox VM status as a safe dry-run by default.
Follows the same broker flow as list_nodes: register and persist
the agent state on the first call, load the persisted token on
later calls, fetch the coreci/proxmox credential, then act. The
dry-run block is the default and no live Proxmox client is wired
in - replace it with a real read-only status call for live use.
:param vm_id: Proxmox VM identifier to look up
:param context: optional Open WebUI call context, unused
:return: dry-run response dict with credential presence
"""
return self._dry_run("vm_status", self._credential())
+2 -2
View File
@@ -29,11 +29,11 @@ class AcceptanceP1Tests(unittest.TestCase):
broker = coreci_broker.boot(dict(self.env))
return broker, buffer.getvalue()
def test_ac14_coreci_broker_line_count_under_1000(self):
def test_ac14_coreci_broker_line_count_under_1400(self):
source_path = os.path.join(os.path.dirname(__file__), "..", "coreci_broker.py")
with open(source_path, "r", encoding="utf-8") as handle:
line_count = sum(1 for _ in handle)
self.assertLess(line_count, 1000)
self.assertLess(line_count, 1400)
def test_first_boot_prints_pass_once_second_boot_silent_with_0600_state(self):
first = io.StringIO()
+309
View File
@@ -0,0 +1,309 @@
import base64
import contextlib
import datetime
import hashlib
import io
import json
import os
import sys
import tempfile
import time
import unittest
import urllib.error
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"
AGENT_PATH = "/api/broker/agents/%s"
STATE_PATH = "/api/broker/agents/%s/state"
AUTH_401_BODY = {"error": 401, "message": "invalid app credentials"}
REGISTER_403_BODY = {"error": 403, "message": "registration not allowed"}
def _basic_header(username, password):
raw = "%s:%s" % (username, password)
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("ascii")
def _derive(pepper, app_uuid, user_id, agent_id, tags):
material = "%s:%s:%s:%s:%s" % (
pepper, app_uuid, user_id, agent_id, ",".join(sorted(tags))
)
digest = hashlib.sha256(material.encode("utf-8")).digest()
return base64.b64encode(digest).decode("ascii")
class AcceptanceP3Tests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.tmp = tmp.name
self.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,
}
self.broker, self.base = self._boot()
self.admin = {"Authorization": _basic_header(ADMIN_USER, ADMIN_PASS)}
self.app_uuid = str(uuidlib.uuid4())
self.user_id = "ceo"
self._seed(self.base, self.app_uuid, self.user_id)
def _boot(self, extra=None):
env = dict(self.env)
env.update(extra or {})
with contextlib.redirect_stdout(io.StringIO()):
broker = coreci_broker.boot(env)
self.addCleanup(broker.audit.close)
self.addCleanup(broker.store.close)
server = coreci_broker.serve("127.0.0.1", 0, broker)
self.addCleanup(server.shutdown)
self.addCleanup(server.server_close)
return broker, "http://127.0.0.1:%d" % server.server_address[1]
def _request(self, method, path, payload=None, headers=None, base=None):
data = None if payload is None else json.dumps(payload).encode("utf-8")
request = urllib.request.Request(
(base or 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, base, app_uuid, user_id):
status, body = self._request(
"POST", APPS_PATH, {"uuid": app_uuid}, headers=self.admin, base=base
)
self.assertEqual(201, status)
status, body = self._request(
"POST", USERS_PATH, {"user_id": user_id}, headers=self.admin, base=base
)
self.assertEqual(201, status)
def _register(self, agent_id, tags, base=None, app_uuid=None, user_id=None):
payload = {
"agent_id": agent_id,
"app_registration_uuid": app_uuid or self.app_uuid,
"user_id": user_id or self.user_id,
"tags": list(tags),
}
return self._request("POST", REGISTER_PATH, payload, base=base)
def _app_header(self, token, app_uuid=None):
return {"Authorization": "App %s %s" % (app_uuid or self.app_uuid, token)}
def _audit_entries(self, base, query):
status, body = self._request(
"GET", AUDIT_PATH + query, headers=self.admin, base=base
)
self.assertEqual(200, status)
return body["entries"]
def test_ac4_derivation_identical_both_sides_stored_as_slow_hash(self):
agent_id = "px-1"
tags = ["zeta", "alpha"]
status, body = self._register(agent_id, tags)
self.assertEqual(201, status)
self.assertTrue(body["created"])
token = body["token"]
pepper = self.broker.store.get_config("BROKER_INSTANCE_PEPPER")
self.assertEqual(pepper, body["pepper"])
broker_side = _derive(pepper, self.app_uuid, self.user_id, agent_id, tags)
tool_side = _derive(body["pepper"], self.app_uuid, self.user_id, agent_id, tags)
self.assertEqual(broker_side, token)
self.assertEqual(tool_side, token)
self.assertEqual(44, len(token))
self.assertTrue(token.isascii())
row = self.broker.store.get_agent(agent_id, self.app_uuid)
stored = row[4]
self.assertNotEqual(token, stored)
if coreci_broker.bcrypt is None:
self.assertTrue(stored.startswith("scrypt$"))
else:
self.assertTrue(stored.startswith(coreci_broker.BCRYPT_PREFIXES))
self.assertTrue(coreci_broker.verify_token(token, stored))
self.assertFalse(coreci_broker.verify_token(token, token))
self.assertEqual(self.user_id, row[2])
self.assertEqual(json.dumps(sorted(tags)), row[3])
self.assertEqual(body["token_expires_at"], row[5])
def test_ac7_revoked_or_unknown_uuid_denied_existing_token_honored(self):
status, first = self._register("tool-1", ["coreci"])
self.assertEqual(201, status)
token = first["token"]
status, body = self._request(
"DELETE", APPS_PATH + "/" + self.app_uuid, headers=self.admin
)
self.assertEqual(200, status)
self.assertEqual({"uuid": self.app_uuid, "status": "revoked"}, body)
status, body = self._register("tool-2", ["coreci"])
self.assertEqual(403, status)
self.assertEqual(REGISTER_403_BODY, body)
self.assertIsNone(self.broker.store.get_agent("tool-2", self.app_uuid))
status, body = self._register("tool-3", ["coreci"], app_uuid=str(uuidlib.uuid4()))
self.assertEqual(403, status)
self.assertEqual(REGISTER_403_BODY, body)
status, body = self._request(
"GET", AGENT_PATH % "tool-1", headers=self._app_header(token)
)
self.assertEqual(200, status)
self.assertEqual("tool-1", body["agent_id"])
self.assertEqual(self.user_id, body["user_id"])
self.assertEqual(self.app_uuid, body["app_registration_uuid"])
self.assertEqual(["coreci"], body["tags"])
def test_ac8_unknown_or_revoked_user_denied(self):
status, body = self._register("tool-1", ["coreci"], user_id="intern")
self.assertEqual(403, status)
self.assertEqual(REGISTER_403_BODY, body)
status, body = self._request(
"POST", USERS_PATH, {"user_id": "intern"}, headers=self.admin
)
self.assertEqual(201, status)
status, body = self._register("tool-1", ["coreci"], user_id="intern")
self.assertEqual(201, status)
self.assertTrue(body["created"])
status, body = self._request(
"DELETE", USERS_PATH + "/intern", headers=self.admin
)
self.assertEqual(200, status)
self.assertEqual({"user_id": "intern", "status": "revoked"}, body)
status, body = self._register("tool-1", ["coreci"], user_id="intern")
self.assertEqual(403, status)
self.assertEqual(REGISTER_403_BODY, body)
def test_ac10_expiry_401_reregister_refreshes_expiry_and_audits(self):
broker, base = self._boot({
"BROKER_DB_PATH": os.path.join(self.tmp, "ttl.db"),
"BROKER_AUDIT_LOG_PATH": os.path.join(self.tmp, "ttl.audit.log"),
"BROKER_TOKEN_TTL": "1",
})
self._seed(base, self.app_uuid, self.user_id)
agent_id = "ttl-tool"
status, first = self._register(agent_id, ["coreci"], base=base)
self.assertEqual(201, status)
delta = (datetime.datetime.fromisoformat(first["token_expires_at"])
- datetime.datetime.now(datetime.timezone.utc)).total_seconds()
self.assertGreater(delta, 0)
self.assertLess(delta, 3)
status, body = self._request(
"GET", AGENT_PATH % agent_id,
headers=self._app_header(first["token"]), base=base,
)
self.assertEqual(200, status)
time.sleep(1.2)
status, body = self._request(
"GET", AGENT_PATH % agent_id,
headers=self._app_header(first["token"]), base=base,
)
self.assertEqual(401, status)
self.assertEqual(AUTH_401_BODY, body)
status, body = self._register(agent_id, ["coreci"], base=base, user_id="ghost-user")
self.assertEqual(403, status)
status, second = self._register(agent_id, ["coreci"], base=base)
self.assertEqual(200, status)
self.assertFalse(second["created"])
self.assertEqual(first["token"], second["token"])
self.assertIn("pepper", second)
self.assertGreater(
datetime.datetime.fromisoformat(second["token_expires_at"]),
datetime.datetime.fromisoformat(first["token_expires_at"]),
)
status, body = self._request(
"GET", AGENT_PATH % agent_id,
headers=self._app_header(second["token"]), base=base,
)
self.assertEqual(200, status)
row = broker.store.get_agent(agent_id, self.app_uuid)
self.assertEqual(second["token_expires_at"], row[5])
self.assertTrue(coreci_broker.verify_token(second["token"], row[4]))
entries = self._audit_entries(base, "?action=register")
self.assertEqual(["ok", "denied", "ok"], [entry["result"] for entry in entries])
for entry in entries:
self.assertEqual(agent_id, entry["agent_id"])
self.assertEqual(self.app_uuid, entry["app_registration_uuid"])
self.assertEqual(REGISTER_PATH, entry["path"])
self.assertEqual(self.user_id, entries[0]["user_id"])
self.assertEqual("ghost-user", entries[1]["user_id"])
self.assertEqual(self.user_id, entries[2]["user_id"])
def test_ac12_state_roundtrip_with_identity_enforcement(self):
status, a = self._register("tool-a", ["coreci", "alpha"])
self.assertEqual(201, status)
status, b = self._register("tool-b", ["coreci"])
self.assertEqual(201, status)
state = {
"agent_id": "tool-a",
"app_registration_uuid": self.app_uuid,
"user_id": self.user_id,
"tags": ["alpha", "coreci"],
"pepper": a["pepper"],
"current_token": a["token"],
"token_expires_at": a["token_expires_at"],
"first_registered_at": "t0",
"last_seen_at": "t1",
"payload": {"counter": 7, "notes": ["x", "y"], "nested": {"k": None}},
}
status, body = self._request(
"PUT", STATE_PATH % "tool-a", state, headers=self._app_header(a["token"])
)
self.assertEqual(200, status)
self.assertEqual({"ok": True}, body)
status, body = self._request(
"GET", STATE_PATH % "tool-a", headers=self._app_header(a["token"])
)
self.assertEqual(200, status)
self.assertEqual(state, body)
self.assertEqual(list(state), list(body))
status, body = self._request(
"GET", STATE_PATH % "tool-a", headers=self._app_header(b["token"])
)
self.assertEqual(403, status)
self.assertEqual({"error": 403, "message": "agent identity mismatch"}, body)
status, body = self._request(
"PUT", STATE_PATH % "tool-a", {"intruder": True},
headers=self._app_header(b["token"]),
)
self.assertEqual(403, status)
self.assertEqual({"error": 403, "message": "agent identity mismatch"}, body)
status, body = self._request(
"PUT", STATE_PATH % "tool-b", {"mine": True},
headers=self._app_header(b["token"]),
)
self.assertEqual(200, status)
status, body = self._request(
"GET", STATE_PATH % "tool-b", headers=self._app_header(b["token"])
)
self.assertEqual(200, status)
self.assertEqual({"mine": True}, body)
status, body = self._request(
"GET", STATE_PATH % "ghost-agent", headers=self._app_header(a["token"])
)
self.assertEqual(404, status)
self.assertEqual({"error": 404, "message": "not found"}, body)
status, body = self._request(
"GET", STATE_PATH % "tool-a", headers=self._app_header(a["token"])
)
self.assertEqual(200, status)
self.assertEqual(state, body)
if __name__ == "__main__":
unittest.main()
+290
View File
@@ -0,0 +1,290 @@
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()
+134
View File
@@ -0,0 +1,134 @@
import base64
import contextlib
import io
import json
import os
import sys
import tempfile
import unittest
import urllib.error
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"
META_KEYS = {"agent_id", "app_registration_uuid", "user_id", "tags",
"token_expires_at", "first_registered_at", "last_seen_at"}
FORBIDDEN_RAW = ('"token"', '"token_hash"', '"pepper"')
def _basic_header(username, password):
raw = "%s:%s" % (username, password)
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("ascii")
class AgentMetaTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.tmp = tmp.name
self.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,
}
with contextlib.redirect_stdout(io.StringIO()):
self.broker = coreci_broker.boot(dict(self.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.app_uuid, self.user_id)
self.registered = self._register("agent-1", ["b", "a"])
self.token = self.registered["token"]
self.pepper = self.registered["pepper"]
self.app = {"Authorization": "App %s %s" % (self.app_uuid, self.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), raw
except urllib.error.HTTPError as error:
raw = error.read().decode("utf-8")
return error.code, json.loads(raw), raw
def _seed(self, app_uuid, user_id):
status, _, _ = self._request("POST", APPS_PATH, {"uuid": app_uuid}, self.admin)
self.assertEqual(201, status)
status, _, _ = self._request("POST", USERS_PATH, {"user_id": 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": tags}
status, body, _ = self._request("POST", REGISTER_PATH, payload)
self.assertEqual(201, status)
return body
def _meta(self, agent_id, headers=None):
return self._request(
"GET", "/api/broker/agents/" + agent_id, headers=headers if headers is not None else self.app
)
def test_identity_match_returns_metadata_only(self):
status, body, raw = self._meta("agent-1")
self.assertEqual(200, status)
self.assertEqual(META_KEYS, set(body))
self.assertEqual("agent-1", body["agent_id"])
self.assertEqual(self.app_uuid, body["app_registration_uuid"])
self.assertEqual(self.user_id, body["user_id"])
self.assertEqual(self.registered["token_expires_at"], body["token_expires_at"])
row = self.broker.store.get_agent("agent-1", self.app_uuid)
self.assertEqual(row[6], body["first_registered_at"])
self.assertEqual(row[7], body["last_seen_at"])
for forbidden in FORBIDDEN_RAW:
self.assertNotIn(forbidden, raw)
self.assertNotIn(self.token, raw)
self.assertNotIn(self.pepper, raw)
def test_tags_roundtrip_is_sorted_canonical_list(self):
status, body, _ = self._meta("agent-1")
self.assertEqual(200, status)
self.assertEqual(["a", "b"], body["tags"])
def test_url_agent_id_mismatch_is_403(self):
self._register("agent-2", ["coreci"])
status, body, raw = self._meta("agent-2")
self.assertEqual(403, status)
self.assertEqual(403, body["error"])
self.assertTrue(body["message"])
for forbidden in FORBIDDEN_RAW:
self.assertNotIn(forbidden, raw)
def test_unknown_agent_id_is_404(self):
status, body, _ = self._meta("ghost-agent")
self.assertEqual(404, status)
self.assertEqual({"error": 404, "message": "not found"}, body)
def test_missing_app_header_is_401(self):
status, body, _ = self._meta("agent-1", headers={})
self.assertEqual(401, status)
self.assertEqual({"error": 401, "message": "invalid app credentials"}, body)
if __name__ == "__main__":
unittest.main()
+263
View File
@@ -0,0 +1,263 @@
import base64
import contextlib
import datetime
import io
import json
import os
import sys
import tempfile
import unittest
import urllib.error
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"
PROTECTED_PATH = "/test/app-auth"
AUTH_401_BODY = {"error": 401, "message": "invalid app credentials"}
def _basic_header(username, password):
raw = "%s:%s" % (username, password)
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("ascii")
def _protected_echo(request):
request.send_json(200, dict(request.app_auth))
class AppAuthTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.tmp = tmp.name
self.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,
}
with contextlib.redirect_stdout(io.StringIO()):
self.broker = coreci_broker.boot(dict(self.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.app_uuid, self.user_id)
self.broker.router.add(
"GET", PROTECTED_PATH, coreci_broker._require_app(_protected_echo)
)
self.token = self._register("agent-1")["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:
return response.status, json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
return error.code, json.loads(error.read().decode("utf-8"))
def _seed(self, app_uuid, user_id):
status, _ = self._request("POST", APPS_PATH, {"uuid": app_uuid}, self.admin)
self.assertEqual(201, status)
status, _ = self._request("POST", USERS_PATH, {"user_id": user_id}, self.admin)
self.assertEqual(201, status)
def _register(self, agent_id):
payload = {
"agent_id": agent_id,
"app_registration_uuid": self.app_uuid,
"user_id": self.user_id,
"tags": ["coreci"],
}
status, body = self._request("POST", REGISTER_PATH, payload)
self.assertEqual(201, status)
return body
def _auth(self, authorization):
headers = {} if authorization is None else {"Authorization": authorization}
return self._request("GET", PROTECTED_PATH, headers=headers)
def _app_header(self, token=None, app_uuid=None):
return "App %s %s" % (app_uuid or self.app_uuid, token or self.token)
def _revoke_app(self):
status, _ = self._request(
"DELETE", APPS_PATH + "/" + self.app_uuid, headers=self.admin
)
self.assertEqual(200, status)
def _patch_column(self, agent_id, column, value):
self.broker.store.conn.execute(
"UPDATE agent_registrations SET %s = ? WHERE agent_id = ?" % column,
(value, agent_id),
)
self.broker.store.conn.commit()
def _count_verify_calls(self):
counter = {"calls": 0}
original = coreci_broker.verify_token
def counted(token, stored):
counter["calls"] += 1
return original(token, stored)
coreci_broker.verify_token = counted
self.addCleanup(setattr, coreci_broker, "verify_token", original)
return counter
def _past(self):
return (datetime.datetime.now(datetime.timezone.utc)
- datetime.timedelta(seconds=60)).isoformat()
def _future(self):
return (datetime.datetime.now(datetime.timezone.utc)
+ datetime.timedelta(seconds=3600)).isoformat()
def test_valid_header_returns_context_and_updates_last_seen(self):
past = self._past()
self._patch_column("agent-1", "last_seen_at", past)
status, body = self._auth(self._app_header())
self.assertEqual(200, status)
self.assertEqual("agent-1", body["agent_id"])
self.assertEqual(self.user_id, body["user_id"])
self.assertEqual(self.app_uuid, body["app_registration_uuid"])
row = self.broker.store.get_agent("agent-1", self.app_uuid)
self.assertEqual(row[5], body["token_expires_at"])
self.assertGreater(row[7], past)
def test_401_matrix_uniform_contract(self):
cases = (
(None),
(_basic_header(ADMIN_USER, ADMIN_PASS)),
("App"),
("App %s" % self.app_uuid),
("App %s %s extra" % (self.app_uuid, self.token)),
(self._app_header(app_uuid=str(uuidlib.uuid4()))),
(self._app_header(token="A" * 44)),
("App not-a-uuid %s" % self.token),
("App %s %s" % (self.app_uuid.upper(), self.token)),
)
for header in cases:
with self.subTest(header=header):
status, body = self._auth(header)
self.assertEqual(401, status)
self.assertEqual(AUTH_401_BODY, body)
def test_cache_first_success_scans_then_identical_call_hits_cache(self):
counter = self._count_verify_calls()
status, body = self._auth(self._app_header())
self.assertEqual(200, status)
self.assertEqual(1, counter["calls"])
status, body = self._auth(self._app_header())
self.assertEqual(200, status)
self.assertEqual("agent-1", body["agent_id"])
self.assertEqual(1, counter["calls"])
self.assertIn((self.app_uuid, self.token), self.broker.token_cache)
def test_clear_token_cache_forces_reverify(self):
counter = self._count_verify_calls()
self._auth(self._app_header())
self.assertEqual(1, counter["calls"])
self.broker.clear_token_cache()
self.assertEqual({}, self.broker.token_cache)
status, _ = self._auth(self._app_header())
self.assertEqual(200, status)
self.assertEqual(2, counter["calls"])
def test_cache_hit_revoked_app_still_200_until_ttl(self):
counter = self._count_verify_calls()
self._auth(self._app_header())
self._revoke_app()
status, body = self._auth(self._app_header())
self.assertEqual(200, status)
self.assertEqual("agent-1", body["agent_id"])
self.assertEqual(1, counter["calls"])
row = self.broker.store.get_row("app_registrations", "uuid", self.app_uuid)
self.assertEqual("revoked", row[2])
def test_revoked_app_first_auth_still_200(self):
self._revoke_app()
counter = self._count_verify_calls()
status, body = self._auth(self._app_header())
self.assertEqual(200, status)
self.assertEqual("agent-1", body["agent_id"])
self.assertEqual(1, counter["calls"])
def test_revoked_app_expired_row_is_401(self):
self._revoke_app()
self._patch_column("agent-1", "token_expires_at", self._past())
status, body = self._auth(self._app_header())
self.assertEqual(401, status)
self.assertEqual(AUTH_401_BODY, body)
def test_cache_hit_expired_row_401_then_evicted_and_reverifies(self):
counter = self._count_verify_calls()
self._auth(self._app_header())
self.assertEqual(1, counter["calls"])
self._patch_column("agent-1", "token_expires_at", self._past())
status, body = self._auth(self._app_header())
self.assertEqual(401, status)
self.assertEqual(AUTH_401_BODY, body)
self.assertEqual(1, counter["calls"])
self.assertEqual({}, self.broker.token_cache)
self._patch_column("agent-1", "token_expires_at", self._future())
status, body = self._auth(self._app_header())
self.assertEqual(200, status)
self.assertEqual(2, counter["calls"])
self.assertIn((self.app_uuid, self.token), self.broker.token_cache)
def test_cache_hit_row_absent_is_401(self):
self._auth(self._app_header())
self.broker.store.conn.execute(
"DELETE FROM agent_registrations WHERE agent_id = ?", ("agent-1",)
)
self.broker.store.conn.commit()
status, body = self._auth(self._app_header())
self.assertEqual(401, status)
self.assertEqual(AUTH_401_BODY, body)
self.assertEqual({}, self.broker.token_cache)
def test_scrypt_hash_format_authenticates(self):
token2 = self._register("agent-2")["token"]
original_bcrypt = coreci_broker.bcrypt
coreci_broker.bcrypt = None
try:
scrypt_hash = coreci_broker.hash_token(token2)
finally:
coreci_broker.bcrypt = original_bcrypt
self.assertTrue(scrypt_hash.startswith("scrypt$"))
self._patch_column("agent-2", "token_hash", scrypt_hash)
status, body = self._auth(self._app_header(token=token2))
self.assertEqual(200, status)
self.assertEqual("agent-2", body["agent_id"])
def test_bcrypt_hash_format_authenticates(self):
if coreci_broker.bcrypt is None:
self.skipTest("bcrypt not importable in this environment")
token3 = self._register("agent-3")["token"]
stored = self.broker.store.get_agent("agent-3", self.app_uuid)[4]
self.assertTrue(stored.startswith("$2"))
status, body = self._auth(self._app_header(token=token3))
self.assertEqual(200, status)
self.assertEqual("agent-3", body["agent_id"])
if __name__ == "__main__":
unittest.main()
+188
View File
@@ -0,0 +1,188 @@
import base64
import contextlib
import datetime
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"
STATE_PATH = "/api/broker/agents/%s/state"
AUTH_401_BODY = {"error": 401, "message": "invalid app credentials"}
BAD_JSON_BODY = {"error": 400, "message": "invalid JSON body"}
NEST_DEPTH = 50000
def _basic_header(username, password):
raw = "%s:%s" % (username, password)
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("ascii")
class AuthAuditTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.tmp = tmp.name
self.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,
}
with contextlib.redirect_stdout(io.StringIO()):
self.broker = coreci_broker.boot(dict(self.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.agent_id = "agent-1"
self._seed(self.app_uuid, self.user_id)
self.token = self._register()["token"]
self.app = {"Authorization": "App %s %s" % (self.app_uuid, self.token)}
def _raw(self, method, path, data, headers):
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:
return response.status, json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
return error.code, json.loads(error.read().decode("utf-8"))
def _request(self, method, path, payload=None, headers=None):
data = None if payload is None else json.dumps(payload).encode("utf-8")
return self._raw(method, path, data, headers)
def _seed(self, app_uuid, user_id):
status, _ = self._request("POST", APPS_PATH, {"uuid": app_uuid}, self.admin)
self.assertEqual(201, status)
status, _ = self._request("POST", USERS_PATH, {"user_id": user_id}, self.admin)
self.assertEqual(201, status)
def _register(self):
payload = {"agent_id": self.agent_id, "app_registration_uuid": self.app_uuid,
"user_id": self.user_id, "tags": ["coreci"]}
status, body = self._request("POST", REGISTER_PATH, payload)
self.assertEqual(201, status)
return body
def _denied(self):
status, body = self._request("GET", AUDIT_PATH + "?action=denied",
headers=self.admin)
self.assertEqual(200, status)
return body["entries"]
def _assert_denied_line(self, entry, app_uuid, path):
self.assertEqual("denied", entry["action"])
self.assertEqual("denied", entry["result"])
self.assertEqual(app_uuid, entry["app_registration_uuid"])
self.assertEqual(path, entry["path"])
self.assertIsNone(entry["user_id"])
self.assertIsNone(entry["agent_id"])
def test_deep_nested_json_body_is_400_not_500(self):
data = ("[" * NEST_DEPTH).encode("utf-8")
self.assertLess(len(data), 1024 * 1024)
status, body = self._raw("PUT", STATE_PATH % self.agent_id, data, self.app)
self.assertEqual(400, status)
self.assertEqual(BAD_JSON_BODY, body)
status, body = self._request("GET", "/healthz")
self.assertEqual(200, status)
self.assertEqual({"ok": True}, body)
def test_wrong_token_denied_audit_carries_uuid_and_path(self):
path = STATE_PATH % self.agent_id
status, body = self._request(
"GET", path, headers={"Authorization": "App %s %s" % (self.app_uuid, "A" * 44)}
)
self.assertEqual(401, status)
self.assertEqual(AUTH_401_BODY, body)
entries = self._denied()
self.assertEqual(1, len(entries))
self._assert_denied_line(entries[0], self.app_uuid, path)
def test_expired_or_absent_token_same_denied_audit(self):
path = STATE_PATH % self.agent_id
past = (datetime.datetime.now(datetime.timezone.utc)
- datetime.timedelta(seconds=60)).isoformat()
self.broker.store.conn.execute(
"UPDATE agent_registrations SET token_expires_at = ? WHERE agent_id = ?",
(past, self.agent_id),
)
self.broker.store.conn.commit()
status, body = self._request("GET", path, headers=self.app)
self.assertEqual(401, status)
self.assertEqual(AUTH_401_BODY, body)
status, body = self._request(
"GET", path, headers={"Authorization": "App %s" % self.app_uuid}
)
self.assertEqual(401, status)
self.assertEqual(AUTH_401_BODY, body)
entries = self._denied()
self.assertEqual(2, len(entries))
for entry in entries:
self._assert_denied_line(entry, self.app_uuid, path)
def test_missing_app_header_401_writes_no_denied_audit(self):
self.assertEqual(0, len(self._denied()))
path = STATE_PATH % self.agent_id
status, body = self._request("GET", path, headers={})
self.assertEqual(401, status)
self.assertEqual(AUTH_401_BODY, body)
status, body = self._request("GET", path, headers=self.admin)
self.assertEqual(401, status)
self.assertEqual(AUTH_401_BODY, body)
self.assertEqual(0, len(self._denied()))
def test_malformed_uuid_denied_audit_carries_null_uuid(self):
path = STATE_PATH % self.agent_id
status, body = self._request(
"GET", path, headers={"Authorization": "App not-a-uuid %s" % self.token}
)
self.assertEqual(401, status)
self.assertEqual(AUTH_401_BODY, body)
entries = self._denied()
self.assertEqual(1, len(entries))
self._assert_denied_line(entries[0], None, path)
def test_admin_audit_action_filter_returns_denied_lines(self):
path = STATE_PATH % self.agent_id
for header in ("App %s wrong" % self.app_uuid,
"App not-a-uuid %s" % self.token):
status, _ = self._request("GET", path, headers={"Authorization": header})
self.assertEqual(401, status)
query = urllib.parse.urlencode({"action": "denied"})
status, body = self._request("GET", AUDIT_PATH + "?" + query,
headers=self.admin)
self.assertEqual(200, status)
self.assertEqual(2, len(body["entries"]))
self.assertEqual([self.app_uuid, None],
[entry["app_registration_uuid"] for entry in body["entries"]])
for entry in body["entries"]:
self.assertEqual(path, entry["path"])
if __name__ == "__main__":
unittest.main()
+308
View File
@@ -0,0 +1,308 @@
import ast
import base64
import contextlib
import datetime
import io
import json
import os
import sys
import tempfile
import time
import unittest
import urllib.error
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
import coreci_broker_client
ADMIN_USER = "admin"
ADMIN_PASS = "correct-horse-battery-staple"
APPS_PATH = "/api/broker/app_registrations"
USERS_PATH = "/api/broker/user_allowlist"
PROXMOX_SECRET = {"url": "https://pve.coreci.dev:8006", "token": "pve-tok"}
GITEA_SECRET = {"url": "https://git.coreci.dev", "token": "git-tok"}
CLIENT_SOURCE_PATH = os.path.abspath(
os.path.join(os.path.dirname(__file__), os.pardir, "coreci_broker_client.py")
)
ALLOWED_CLIENT_IMPORTS = {"urllib.request", "urllib.parse", "json", "time"}
SECRETS_PROXMOX_PATH = "/api/broker/secrets/coreci/proxmox"
def _basic_header(username, password):
raw = "%s:%s" % (username, password)
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("ascii")
class ClientHygieneTests(unittest.TestCase):
def setUp(self):
with open(CLIENT_SOURCE_PATH, "r", encoding="utf-8") as handle:
self.source = handle.read()
self.tree = ast.parse(self.source)
def test_imports_are_stdlib_only(self):
modules = set()
for node in ast.walk(self.tree):
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.assertIn("urllib.request", modules)
for module in modules:
self.assertIn(module, ALLOWED_CLIENT_IMPORTS, module)
def test_single_module_docstring_and_no_comments(self):
docstring = ast.get_docstring(self.tree)
self.assertIsNotNone(docstring)
self.assertNotIn("\n", docstring)
for node in ast.walk(self.tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
self.assertIsNone(ast.get_docstring(node))
for number, line in enumerate(self.source.splitlines(), 1):
self.assertFalse(line.lstrip().startswith("#"), "comment on line %d" % number)
def test_broker_error_carries_status_and_message(self):
error = coreci_broker_client.BrokerError(403, "forbidden")
self.assertEqual(403, error.status)
self.assertEqual("forbidden", error.message)
self.assertEqual("forbidden", str(error))
class _ClientCase(unittest.TestCase):
extra_env = {}
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(
{"coreci/proxmox": PROXMOX_SECRET, "coreci/gitea": 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,
}
env.update(self.extra_env)
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 _raw_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._raw_request("POST", APPS_PATH, {"uuid": self.app_uuid}, self.admin)
self.assertEqual(201, status)
status, _ = self._raw_request("POST", USERS_PATH, {"user_id": self.user_id}, self.admin)
self.assertEqual(201, status)
def _client(self, agent_id="proxmox", tags=("coreci",)):
return coreci_broker_client.BrokerClient(
self.base, agent_id, self.app_uuid, self.user_id, list(tags)
)
def _count_requests(self, client):
calls = []
original = client._request
def counting(method, path, payload=None, headers=None):
calls.append((method, path))
return original(method, path, payload, headers)
def restore():
client._request = original
client._request = counting
return calls, restore
class ClientFlowTests(_ClientCase):
def test_ensure_registered_from_empty_state_populates_token_fields(self):
client = self._client()
state = {}
returned = client.ensure_registered(state)
self.assertIs(state, returned)
token = state["current_token"]
self.assertEqual(44, len(token))
self.assertTrue(token.isascii())
expires = datetime.datetime.fromisoformat(state["token_expires_at"])
self.assertGreater(expires, datetime.datetime.now(datetime.timezone.utc))
self.assertEqual("proxmox", state["agent_id"])
self.assertEqual(self.app_uuid, state["app_registration_uuid"])
self.assertEqual(self.user_id, state["user_id"])
self.assertEqual(["coreci"], state["tags"])
status, body = self._raw_request(
"GET", SECRETS_PROXMOX_PATH,
headers={"Authorization": "App %s %s" % (self.app_uuid, token)},
)
self.assertEqual(200, status)
self.assertEqual(PROXMOX_SECRET, body["data"])
def test_first_register_merges_pepper_into_state(self):
client = self._client()
state = client.ensure_registered({})
self.assertIn("pepper", state)
self.assertEqual(
self.broker.store.get_config("BROKER_INSTANCE_PEPPER"), state["pepper"]
)
def test_ensure_registered_with_live_token_makes_no_network_call(self):
client = self._client()
state = client.ensure_registered({})
snapshot = dict(state)
calls, restore = self._count_requests(client)
try:
returned = client.ensure_registered(state)
finally:
restore()
self.assertIs(state, returned)
self.assertEqual([], calls)
self.assertEqual(snapshot, state)
def test_reregister_with_valid_token_pepperless_response_does_not_crash(self):
client = self._client()
state = client.ensure_registered({})
pepper, token, expires = state["pepper"], state["current_token"], state["token_expires_at"]
status, body = self._raw_request(
"POST", coreci_broker_client.REGISTER_PATH,
{"agent_id": "proxmox", "app_registration_uuid": self.app_uuid,
"user_id": self.user_id, "tags": ["coreci"]},
{"Authorization": "App %s %s" % (self.app_uuid, token)},
)
self.assertEqual(200, status)
self.assertNotIn("pepper", body)
returned = client.register(state)
self.assertIs(state, returned)
self.assertEqual(pepper, state["pepper"])
self.assertEqual(token, state["current_token"])
self.assertEqual(expires, state["token_expires_at"])
def test_put_then_get_state_roundtrip_through_broker(self):
client = self._client()
state = client.ensure_registered({})
state["scratchpad"] = {"calls": 3, "notes": ["x"]}
self.assertTrue(client.put_state(state))
document = client.get_state(state)
self.assertEqual(state, document)
def test_get_state_returns_none_before_first_put(self):
client = self._client()
state = client.ensure_registered({})
self.assertIsNone(client.get_state(state))
def test_read_secret_returns_secret_document(self):
client = self._client()
state = client.ensure_registered({})
document = client.read_secret("coreci/proxmox", state)
self.assertEqual("coreci/proxmox", document["path"])
self.assertEqual(PROXMOX_SECRET, document["data"])
self.assertRegex(document["version"], r"^[0-9a-f]{8}$")
self.assertIn("updated_at", document)
def test_cross_path_read_secret_raises_403_broker_error(self):
client = self._client()
state = client.ensure_registered({})
with self.assertRaises(coreci_broker_client.BrokerError) as caught:
client.read_secret("coreci/gitea", state)
self.assertEqual(403, caught.exception.status)
self.assertTrue(caught.exception.message)
def test_missing_secret_read_raises_404_broker_error(self):
client = self._client("nonexistent")
state = client.ensure_registered({})
with self.assertRaises(coreci_broker_client.BrokerError) as caught:
client.read_secret("coreci/nonexistent", state)
self.assertEqual(404, caught.exception.status)
def test_read_secret_from_cold_state_registers_then_retries_once(self):
client = self._client()
state = {}
calls, restore = self._count_requests(client)
try:
document = client.read_secret("coreci/proxmox", state)
finally:
restore()
self.assertEqual(PROXMOX_SECRET, document["data"])
self.assertEqual(
[("GET", SECRETS_PROXMOX_PATH),
("POST", coreci_broker_client.REGISTER_PATH),
("GET", SECRETS_PROXMOX_PATH)],
calls,
)
self.assertIn("current_token", state)
def test_get_state_with_foreign_agent_token_is_403_identity_enforced(self):
intruder = self._client("proxmox")
other = self._client("gitea-agent")
other_state = other.ensure_registered({})
intruder.ensure_registered({})
with self.assertRaises(coreci_broker_client.BrokerError) as caught:
intruder.get_state({"current_token": other_state["current_token"]})
self.assertEqual(403, caught.exception.status)
class TtlAutoReregisterTests(_ClientCase):
extra_env = {"BROKER_TOKEN_TTL": "1"}
def test_expired_token_secret_read_auto_reregisters_and_retries(self):
client = self._client()
state = {}
first = client.read_secret("coreci/proxmox", state)
self.assertEqual(PROXMOX_SECRET, first["data"])
first_expires = datetime.datetime.fromisoformat(state["token_expires_at"])
time.sleep(1.2)
second = client.read_secret("coreci/proxmox", state)
self.assertEqual(PROXMOX_SECRET, second["data"])
second_expires = datetime.datetime.fromisoformat(state["token_expires_at"])
self.assertGreater(second_expires, first_expires)
self.assertIn("pepper", state)
class StaticDeliveryTests(_ClientCase):
def test_fetch_client_module_returns_own_source(self):
with open(CLIENT_SOURCE_PATH, "r", encoding="utf-8") as handle:
source = handle.read()
fetched = coreci_broker_client.fetch_client_module(self.base)
self.assertEqual(source, fetched)
self.assertIn("class BrokerClient", fetched)
def test_client_module_served_unauthenticated(self):
with open(CLIENT_SOURCE_PATH, "r", encoding="utf-8") as handle:
source = handle.read()
with urllib.request.urlopen(
self.base + "/static/coreci_broker_client.py", timeout=10
) as response:
self.assertEqual(200, response.status)
self.assertEqual(source, response.read().decode("utf-8"))
if __name__ == "__main__":
unittest.main()
+312
View File
@@ -0,0 +1,312 @@
import contextlib
import hashlib
import io
import json
import os
import sys
import tempfile
import threading
import unittest
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)))
import coreci_broker
API_KEY = "sk-test-key"
KB_NAME = "creds-coreci-coo"
PROXMOX_SECRET = {"url": "https://pve.coreci.dev:8006", "token": "pve-tok"}
PROXMOX_UPDATED = "2026-10-01T12:00:00Z"
def _standard_fixtures():
kbs = [{"id": "kb-1", "name": KB_NAME}]
files = {"kb-1": [{"name": "coreci/proxmox", "id": "f1", "updated_at": PROXMOX_UPDATED}]}
contents = {"f1": json.dumps(PROXMOX_SECRET)}
return kbs, files, contents
class _StubOwuiHandler(BaseHTTPRequestHandler):
kbs = None
files = None
contents = None
failures = None
hits = None
def log_message(self, format, *args):
pass
def _send(self, status, payload):
data = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_GET(self):
parsed = urllib.parse.urlsplit(self.path)
path = parsed.path
self.hits.append((path, self.headers.get("Authorization")))
if path in self.failures:
self._send(self.failures[path], {"error": self.failures[path]})
elif path == "/api/v1/knowledge/":
page = max(1, int((urllib.parse.parse_qs(parsed.query).get("page") or ["1"])[0]))
self._send(200, {"items": self.kbs[(page - 1) * 30:page * 30], "total": len(self.kbs)})
elif path.startswith("/api/v1/knowledge/"):
kb_id = path[len("/api/v1/knowledge/"):]
kb = next((entry for entry in self.kbs if entry["id"] == kb_id), None)
if kb is None:
self._send(404, {"error": 404})
else:
self._send(200, dict(kb, files=self.files.get(kb_id, [])))
elif path.startswith("/api/v1/files/") and path.endswith("/data/content"):
file_id = path[len("/api/v1/files/"):-len("/data/content")]
if file_id in self.contents:
self._send(200, {"content": self.contents[file_id]})
else:
self._send(404, {"error": 404})
else:
self._send(404, {"error": 404})
class StubOwuiServer:
def __init__(self, kbs, files, contents):
self.kbs, self.files, self.contents, self.failures = kbs, files, contents, {}
self.hits = []
handler = type("BoundStubHandler", (_StubOwuiHandler,), {
"kbs": kbs, "files": files, "contents": contents,
"failures": self.failures, "hits": self.hits,
})
self.server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
threading.Thread(target=self.server.serve_forever, daemon=True).start()
@property
def base_url(self):
return "http://127.0.0.1:%d" % self.server.server_address[1]
def hits_for(self, path):
return [hit for hit in self.hits if hit[0] == path]
def close(self):
self.server.shutdown()
self.server.server_close()
class FileCredsSourceTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.creds_path = os.path.join(tmp.name, "creds.json")
self.document = {
"coreci/proxmox": {"url": "https://pve.coreci.dev:8006", "token": "pve-tok"},
"coreci/gitea": {"url": "https://git.coreci.dev", "token": "git-tok"},
}
self._write(self.document)
self.source = coreci_broker.FileCredsSource(self.creds_path)
def _write(self, document):
with open(self.creds_path, "w", encoding="utf-8") as handle:
json.dump(document, handle)
def test_fetches_both_paths_with_response_shape(self):
for path, secret in self.document.items():
result = self.source.get_secret(path)
self.assertEqual(path, result["path"])
self.assertEqual(secret, result["data"])
self.assertIsNone(result["updated_at"])
self.assertRegex(result["version"], r"^[0-9a-f]{8}$")
def test_version_is_short_sha256_of_serialized_data(self):
result = self.source.get_secret("coreci/proxmox")
serialized = json.dumps(result["data"], sort_keys=True, separators=(",", ":"))
expected = hashlib.sha256(serialized.encode("utf-8")).hexdigest()[:8]
self.assertEqual(expected, result["version"])
def test_equal_data_yields_equal_versions(self):
first = self.source.get_secret("coreci/proxmox")
second = self.source.get_secret("coreci/proxmox")
self.assertEqual(first["version"], second["version"])
def test_missing_path_is_not_found(self):
with self.assertRaises(coreci_broker._SecretNotFound):
self.source.get_secret("coreci/ghost")
def test_missing_file_means_all_paths_not_found(self):
os.remove(self.creds_path)
for path in self.document:
with self.assertRaises(coreci_broker._SecretNotFound):
self.source.get_secret(path)
def test_invalid_json_is_source_error(self):
with open(self.creds_path, "w", encoding="utf-8") as handle:
handle.write("{not-json")
with self.assertRaises(coreci_broker._CredsSourceError):
self.source.get_secret("coreci/proxmox")
def test_non_object_document_is_source_error(self):
self._write(["not", "a", "mapping"])
with self.assertRaises(coreci_broker._CredsSourceError):
self.source.get_secret("coreci/proxmox")
def test_fresh_read_sees_out_of_band_edits(self):
first = self.source.get_secret("coreci/proxmox")
self.document["coreci/proxmox"]["token"] = "rotated"
self._write(self.document)
second = self.source.get_secret("coreci/proxmox")
self.assertEqual("pve-tok", first["data"]["token"])
self.assertEqual("rotated", second["data"]["token"])
self.assertNotEqual(first["version"], second["version"])
class OwuiApiCredsSourceTests(unittest.TestCase):
def setUp(self):
kbs, files, contents = _standard_fixtures()
self.stub = StubOwuiServer(kbs, files, contents)
self.addCleanup(self.stub.close)
self.source = coreci_broker.OwuiApiCredsSource(self.stub.base_url, API_KEY)
def test_roundtrip_returns_response_shape(self):
result = self.source.get_secret("coreci/proxmox")
self.assertEqual("coreci/proxmox", result["path"])
self.assertEqual(PROXMOX_SECRET, result["data"])
self.assertEqual(PROXMOX_UPDATED, result["updated_at"])
self.assertRegex(result["version"], r"^[0-9a-f]{8}$")
def test_kb_and_file_map_cached_across_calls(self):
self.source.get_secret("coreci/proxmox")
self.source.get_secret("coreci/proxmox")
self.assertEqual(1, len(self.stub.hits_for("/api/v1/knowledge/")))
self.assertEqual(1, len(self.stub.hits_for("/api/v1/knowledge/kb-1")))
self.assertEqual(2, len(self.stub.hits_for("/api/v1/files/f1/data/content")))
def test_bearer_header_present_on_every_upstream_call(self):
self.source.get_secret("coreci/proxmox")
self.assertTrue(self.stub.hits)
for _, auth in self.stub.hits:
self.assertEqual("Bearer " + API_KEY, auth)
def test_refresh_on_miss_picks_up_newly_added_files(self):
self.source.get_secret("coreci/proxmox")
self.stub.files["kb-1"].append(
{"name": "coreci/gitea", "id": "f2", "updated_at": "2026-10-02T00:00:00Z"})
self.stub.contents["f2"] = json.dumps({"user": "ceo"})
result = self.source.get_secret("coreci/gitea")
self.assertEqual({"user": "ceo"}, result["data"])
self.assertEqual("2026-10-02T00:00:00Z", result["updated_at"])
self.assertEqual(2, len(self.stub.hits_for("/api/v1/knowledge/")))
def test_unknown_path_is_not_found(self):
with self.assertRaises(coreci_broker._SecretNotFound):
self.source.get_secret("coreci/ghost")
def test_missing_kb_is_not_found(self):
self.stub.kbs.clear()
with self.assertRaises(coreci_broker._SecretNotFound):
self.source.get_secret("coreci/proxmox")
def test_kb_list_404_is_not_found(self):
self.stub.failures["/api/v1/knowledge/"] = 404
with self.assertRaises(coreci_broker._SecretNotFound):
self.source.get_secret("coreci/proxmox")
def test_kb_detail_404_is_not_found(self):
self.stub.failures["/api/v1/knowledge/kb-1"] = 404
with self.assertRaises(coreci_broker._SecretNotFound):
self.source.get_secret("coreci/proxmox")
def test_file_content_404_is_not_found(self):
del self.stub.contents["f1"]
with self.assertRaises(coreci_broker._SecretNotFound):
self.source.get_secret("coreci/proxmox")
def test_upstream_500_is_source_error_not_crash(self):
self.stub.failures["/api/v1/files/f1/data/content"] = 500
with self.assertRaises(coreci_broker._CredsSourceError):
self.source.get_secret("coreci/proxmox")
def test_kb_list_500_is_source_error(self):
self.stub.failures["/api/v1/knowledge/"] = 500
with self.assertRaises(coreci_broker._CredsSourceError):
self.source.get_secret("coreci/proxmox")
def test_unavailable_upstream_maps_to_distinct_source_error(self):
source = coreci_broker.OwuiApiCredsSource("http://127.0.0.1:9", API_KEY)
with self.assertRaises(coreci_broker._CredsSourceError) as caught:
source.get_secret("coreci/proxmox")
self.assertIn("unavailable", str(caught.exception))
def test_invalid_content_json_is_source_error(self):
self.stub.contents["f1"] = "not-json"
with self.assertRaises(coreci_broker._CredsSourceError):
self.source.get_secret("coreci/proxmox")
def test_pagination_finds_kb_beyond_first_page(self):
self.stub.kbs[:] = [{"id": "kb-%d" % i, "name": "kb-%d" % i} for i in range(30)]
self.stub.kbs.append({"id": "kb-creds", "name": KB_NAME})
self.stub.files["kb-creds"] = [
{"name": "coreci/proxmox", "id": "f1", "updated_at": PROXMOX_UPDATED}]
result = self.source.get_secret("coreci/proxmox")
self.assertEqual(PROXMOX_SECRET, result["data"])
self.assertEqual(2, len(self.stub.hits_for("/api/v1/knowledge/")))
class CredsSourceFactoryTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.tmp = tmp.name
def _boot(self, extra):
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"),
}
env.update(extra)
with contextlib.redirect_stdout(io.StringIO()):
broker = coreci_broker.boot(env)
self.addCleanup(broker.audit.close)
self.addCleanup(broker.store.close)
return broker
def test_default_source_is_file_with_default_path(self):
broker = self._boot({})
self.assertIsInstance(broker.creds_source, coreci_broker.FileCredsSource)
self.assertEqual("data/creds.json", broker.creds_source.path)
def test_explicit_file_source_with_custom_path(self):
creds_path = os.path.join(self.tmp, "custom-creds.json")
with open(creds_path, "w", encoding="utf-8") as handle:
json.dump({"coreci/proxmox": PROXMOX_SECRET}, handle)
broker = self._boot({
"BROKER_CREDS_SOURCE": "file",
"BROKER_CREDS_FILE": creds_path,
})
self.assertIsInstance(broker.creds_source, coreci_broker.FileCredsSource)
self.assertEqual(creds_path, broker.creds_source.path)
self.assertEqual(PROXMOX_SECRET, broker.creds_source.get_secret("coreci/proxmox")["data"])
def test_owui_source_selected_by_config(self):
kbs, files, contents = _standard_fixtures()
stub = StubOwuiServer(kbs, files, contents)
self.addCleanup(stub.close)
broker = self._boot({
"BROKER_CREDS_SOURCE": "owui",
"BROKER_OWUI_BASE_URL": stub.base_url,
"BROKER_OWUI_API_KEY": API_KEY,
})
source = broker.creds_source
self.assertIsInstance(source, coreci_broker.OwuiApiCredsSource)
self.assertEqual(stub.base_url, source.base_url)
self.assertEqual(API_KEY, source.api_key)
self.assertEqual(PROXMOX_SECRET, source.get_secret("coreci/proxmox")["data"])
def test_unknown_source_value_raises_at_boot(self):
with self.assertRaises(ValueError):
self._boot({"BROKER_CREDS_SOURCE": "banana"})
if __name__ == "__main__":
unittest.main()
+249
View File
@@ -0,0 +1,249 @@
import base64
import contextlib
import datetime
import io
import json
import os
import sys
import tempfile
import unittest
import urllib.error
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"
PROTECTED_PATH = "/test/p3-remediation"
AUTH_401_BODY = {"error": 401, "message": "invalid app credentials"}
CAP_BODY = {"error": 400, "message": "too many agents for this app"}
AGENT_ID_BODY = {"error": 400, "message": "invalid agent_id"}
def _basic_header(username, password):
raw = "%s:%s" % (username, password)
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("ascii")
def _protected_echo(request):
request.send_json(200, dict(request.app_auth))
class P3RemediationTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.tmp = tmp.name
self.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,
}
with contextlib.redirect_stdout(io.StringIO()):
self.broker = coreci_broker.boot(dict(self.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.app_uuid, self.user_id)
self.broker.router.add(
"GET", PROTECTED_PATH, coreci_broker._require_app(_protected_echo)
)
def _request(self, method, path, payload=None, headers=None, base=None):
data = None if payload is None else json.dumps(payload).encode("utf-8")
request = urllib.request.Request(
(base or 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:
return response.status, json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
return error.code, json.loads(error.read().decode("utf-8"))
def _seed(self, app_uuid, user_id, base=None):
status, _ = self._request("POST", APPS_PATH, {"uuid": app_uuid}, self.admin, base)
self.assertEqual(201, status)
status, _ = self._request("POST", USERS_PATH, {"user_id": user_id}, self.admin, base)
self.assertEqual(201, status)
def _register(self, agent_id="agent-1", tags=("coreci",), app_uuid=None,
user_id=None, base=None):
payload = {
"agent_id": agent_id,
"app_registration_uuid": app_uuid or self.app_uuid,
"user_id": user_id or self.user_id,
"tags": list(tags),
}
return self._request("POST", REGISTER_PATH, payload, base=base)
def _auth(self, token, app_uuid=None):
header = "App %s %s" % (app_uuid or self.app_uuid, token)
return self._request("GET", PROTECTED_PATH, headers={"Authorization": header})
def _audit(self, action=None, result=None):
with open(self.env["BROKER_AUDIT_LOG_PATH"], "r", encoding="utf-8") as handle:
entries = [json.loads(raw) for raw in handle if raw.strip()]
return [
entry for entry in entries
if (action is None or entry["action"] == action)
and (result is None or entry["result"] == result)
]
def _future(self, seconds=3600):
return (datetime.datetime.now(datetime.timezone.utc)
+ datetime.timedelta(seconds=seconds)).isoformat()
def test_agent_id_colon_slash_backslash_rejected(self):
for bad in ("a:b", "a/b", "a\\b"):
status, body = self._register(agent_id=bad)
self.assertEqual(400, status, repr(bad))
self.assertEqual(AGENT_ID_BODY, body)
status, body = self._register(agent_id="a:b", app_uuid=str(uuidlib.uuid4()))
self.assertEqual(400, status)
self.assertEqual(AGENT_ID_BODY, body)
self.assertEqual([], self._audit("register"))
self.assertEqual([], self.broker.store.agents_by_uuid(self.app_uuid))
def test_tag_containing_colon_rejected(self):
status, body = self._register(tags=("ok", "ba:d"))
self.assertEqual(400, status)
self.assertEqual({"error": 400, "message": "invalid tags"}, body)
self.assertEqual([], self.broker.store.agents_by_uuid(self.app_uuid))
status, body = self._register(tags=("ok",))
self.assertEqual(201, status)
self.assertTrue(body["created"])
def test_user_id_colon_rejected_at_allowlist_add(self):
status, body = self._request("POST", USERS_PATH, {"user_id": "co:o"}, self.admin)
self.assertEqual(400, status)
self.assertEqual({"error": 400, "message": "invalid user_id"}, body)
status, body = self._register(user_id="co:o")
self.assertEqual(403, status)
self.assertEqual({"error": 403, "message": "registration not allowed"}, body)
self.assertEqual(1, len(self._audit("register", "denied")))
status, body = self._request("POST", USERS_PATH, {"user_id": "coo"}, self.admin)
self.assertEqual(201, status)
status, body = self._register(agent_id="coo-agent", user_id="coo")
self.assertEqual(201, status)
def test_default_fifty_agent_cap_blocks_register(self):
self.assertEqual("50", self.broker.config["BROKER_MAX_AGENTS_PER_APP"])
now = datetime.datetime.now(datetime.timezone.utc).isoformat()
seed_hash = coreci_broker.hash_token("seed")
for index in range(50):
self.broker.store.save_agent(
"seeded-%d" % index, self.app_uuid, self.user_id, "[]",
seed_hash, self._future(), now,
)
status, body = self._register(agent_id="overflow")
self.assertEqual(400, status)
self.assertEqual(CAP_BODY, body)
status, body = self._register(agent_id="seeded-0")
self.assertEqual(400, status)
self.assertEqual(CAP_BODY, body)
self.assertIsNone(self.broker.store.get_agent("overflow", self.app_uuid))
self.assertEqual(50, len(self.broker.store.agents_by_uuid(self.app_uuid)))
self.assertEqual([], self._audit("register"))
def test_max_agents_per_app_env_override_honored(self):
env = dict(self.env)
env["BROKER_DB_PATH"] = os.path.join(self.tmp, "cap.db")
env["BROKER_AUDIT_LOG_PATH"] = os.path.join(self.tmp, "cap.audit.log")
env["BROKER_MAX_AGENTS_PER_APP"] = "2"
with contextlib.redirect_stdout(io.StringIO()):
broker = coreci_broker.boot(dict(env))
self.addCleanup(broker.audit.close)
self.addCleanup(broker.store.close)
self.assertEqual("2", broker.config["BROKER_MAX_AGENTS_PER_APP"])
server = coreci_broker.serve("127.0.0.1", 0, broker)
self.addCleanup(server.shutdown)
self.addCleanup(server.server_close)
base = "http://127.0.0.1:%d" % server.server_address[1]
self._seed(self.app_uuid, self.user_id, base=base)
for agent_id in ("agent-a", "agent-b"):
status, body = self._register(agent_id=agent_id, base=base)
self.assertEqual(201, status, agent_id)
self.assertTrue(body["created"])
status, body = self._register(agent_id="agent-c", base=base)
self.assertEqual(400, status)
self.assertEqual(CAP_BODY, body)
self.assertIsNone(broker.store.get_agent("agent-c", self.app_uuid))
self.assertEqual(2, len(broker.store.agents_by_uuid(self.app_uuid)))
def test_overwrite_evicts_uuid_cache_entries(self):
_, first = self._register(tags=("red",))
old_token = first["token"]
status, _ = self._auth(old_token)
self.assertEqual(200, status)
self.assertIn((self.app_uuid, old_token), self.broker.token_cache)
status, second = self._register(tags=("blue",))
self.assertEqual(200, status)
self.assertFalse(second["created"])
new_token = second["token"]
self.assertNotEqual(old_token, new_token)
self.assertEqual([], [k for k in self.broker.token_cache if k[0] == self.app_uuid])
status, body = self._auth(old_token)
self.assertEqual(401, status)
self.assertEqual(AUTH_401_BODY, body)
self.assertNotIn((self.app_uuid, old_token), self.broker.token_cache)
status, body = self._auth(new_token)
self.assertEqual(200, status)
self.assertEqual("agent-1", body["agent_id"])
self.assertIn((self.app_uuid, new_token), self.broker.token_cache)
row = self.broker.store.get_agent("agent-1", self.app_uuid)
self.assertFalse(coreci_broker.verify_token(old_token, row[4]))
self.assertTrue(coreci_broker.verify_token(new_token, row[4]))
def test_integrity_error_race_takes_idempotent_path(self):
agent_id = "race-agent"
tags = ["coreci"]
pepper = self.broker.store.get_config("BROKER_INSTANCE_PEPPER")
token = coreci_broker.derive_token(pepper, self.app_uuid, self.user_id, agent_id, tags)
expires = self._future()
now = datetime.datetime.now(datetime.timezone.utc).isoformat()
self.broker.store.save_agent(
agent_id, self.app_uuid, self.user_id, json.dumps(sorted(tags)),
coreci_broker.hash_token(token), expires, now,
)
real_get_agent = self.broker.store.get_agent
calls = []
def racing_get_agent(agent_id, app_uuid):
calls.append(agent_id)
if len(calls) == 1:
return None
return real_get_agent(agent_id, app_uuid)
self.broker.store.get_agent = racing_get_agent
self.addCleanup(setattr, self.broker.store, "get_agent", real_get_agent)
status, body = self._register(agent_id=agent_id, tags=tags)
self.assertEqual(200, status)
self.assertFalse(body["created"])
self.assertEqual(token, body["token"])
self.assertEqual(expires, body["token_expires_at"])
self.assertIn("pepper", body)
self.assertEqual(2, len(calls))
row = real_get_agent(agent_id, self.app_uuid)
self.assertTrue(coreci_broker.verify_token(token, row[4]))
self.assertGreaterEqual(row[7], now)
self.assertEqual(1, len(self._audit("register", "ok")))
if __name__ == "__main__":
unittest.main()
+271
View File
@@ -0,0 +1,271 @@
import ast
import base64
import contextlib
import importlib.util
import io
import json
import os
import sys
import tempfile
import time
import unittest
import urllib.error
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"
PROXMOX_SECRET = {"url": "https://pve.coreci.dev:8006", "token": "pve-tok"}
REFERENCE_DIR = os.path.abspath(
os.path.join(os.path.dirname(__file__), os.pardir, "reference_tools")
)
REFERENCE_TITLES = {
"proxmox_readonly_reference": "Proxmox Read-Only Reference [CoreCI]",
"minimal_auth_showcase": "Minimal Auth Showcase [CoreCI]",
}
ALLOWED_REFERENCE_IMPORTS = {"coreci_broker_client", "datetime"}
FORBIDDEN_SOURCE_MARKERS = ("urllib", "httpx", "socket", "proxmoxer")
BROKER_ONLY_AUDIT_ACTIONS = {"add_app", "add_user", "register", "read"}
def _basic_header(username, password):
raw = "%s:%s" % (username, password)
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("ascii")
def _load_reference(name):
spec = importlib.util.spec_from_file_location(
name, os.path.join(REFERENCE_DIR, name + ".py")
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _reference_source(name):
with open(os.path.join(REFERENCE_DIR, name + ".py"), "r", encoding="utf-8") as handle:
return handle.read()
PROXMOX_REFERENCE = _load_reference("proxmox_readonly_reference")
MINIMAL_SHOWCASE = _load_reference("minimal_auth_showcase")
class _ReferenceCase(unittest.TestCase):
extra_env = {}
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({"coreci/proxmox": PROXMOX_SECRET}))
self.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,
}
self.env.update(self.extra_env)
with contextlib.redirect_stdout(io.StringIO()):
self.broker = coreci_broker.boot(self.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 _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 _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 _audit(self, action=None, result=None):
with open(self.env["BROKER_AUDIT_LOG_PATH"], "r", encoding="utf-8") as handle:
entries = [json.loads(raw) for raw in handle if raw.strip()]
return [
entry for entry in entries
if (action is None or entry["action"] == action)
and (result is None or entry["result"] == result)
]
def _tools(self, module, agent_id="proxmox"):
tools = module.Tools()
tools.valves.base_url = self.base
tools.valves.agent_id = agent_id
tools.valves.app_uuid = self.app_uuid
tools.valves.user_id = self.user_id
return tools
def _persisted_state(self, agent_id="proxmox"):
return self.broker.store.get_agent_state(agent_id, self.app_uuid)
class ProxmoxReferenceFlowTests(_ReferenceCase):
def test_first_call_registers_persists_state_reads_secret_dry_runs(self):
tools = self._tools(PROXMOX_REFERENCE)
response = tools.list_nodes()
self.assertIs(True, response["dry_run"])
self.assertEqual("list_nodes", response["action"])
self.assertIs(True, response["credential_present"])
self.assertTrue(str(response["note"]).strip())
self.assertNotIn("data", response)
self.assertNotIn("pve-tok", json.dumps(response))
self.assertEqual(1, len(self._audit(action="register")))
document = self._persisted_state()
self.assertIsNotNone(document)
self.assertIn("current_token", document)
self.assertEqual(tools._state["current_token"], document["current_token"])
self.assertEqual([], self._audit(result="denied"))
self.assertTrue(
{entry["action"] for entry in self._audit()} <= BROKER_ONLY_AUDIT_ACTIONS
)
def test_second_call_reuses_persisted_state_without_new_register(self):
tools = self._tools(PROXMOX_REFERENCE)
tools.list_nodes()
token = tools._state["current_token"]
second = tools.list_nodes()
self.assertIs(True, second["dry_run"])
self.assertEqual("list_nodes", second["action"])
self.assertIs(True, second["credential_present"])
self.assertEqual(1, len(self._audit(action="register")))
self.assertEqual(token, tools._state["current_token"])
document = self._persisted_state()
self.assertIn("current_token", document)
self.assertEqual(token, document["current_token"])
def test_vm_status_is_dry_run_with_credential_present(self):
tools = self._tools(PROXMOX_REFERENCE)
response = tools.vm_status("101")
self.assertIs(True, response["dry_run"])
self.assertEqual("vm_status", response["action"])
self.assertIs(True, response["credential_present"])
self.assertNotIn("pve-tok", json.dumps(response))
self.assertEqual(1, len(self._audit(action="register")))
class ProxmoxReferenceTtlTests(_ReferenceCase):
extra_env = {"BROKER_TOKEN_TTL": "1"}
def test_expired_subsequent_call_reregisters_and_persists_new_expiry(self):
tools = self._tools(PROXMOX_REFERENCE)
self.assertTrue(tools.list_nodes()["credential_present"])
first_expires = tools._state["token_expires_at"]
time.sleep(1.2)
response = tools.list_nodes()
self.assertIs(True, response["dry_run"])
self.assertIs(True, response["credential_present"])
self.assertEqual(2, len(self._audit(action="register")))
document = self._persisted_state()
self.assertEqual(tools._state["current_token"], document["current_token"])
self.assertEqual(tools._state["token_expires_at"], document["token_expires_at"])
self.assertNotEqual(first_expires, document["token_expires_at"])
class MinimalShowcaseTests(_ReferenceCase):
def test_demo_authenticates_then_reads_secret(self):
tools = self._tools(MINIMAL_SHOWCASE)
response = tools.demo()
self.assertEqual(
{"registered": True, "secret_read": True,
"path": "coreci/proxmox", "note": "auth first, then use"},
response,
)
self.assertNotIn("pve-tok", json.dumps(response))
self.assertEqual(1, len(self._audit(action="register")))
self.assertEqual([], self._audit(result="denied"))
class ReferenceHygieneTests(unittest.TestCase):
def test_sources_carry_no_secret_literals(self):
for name in REFERENCE_TITLES:
source = _reference_source(name)
self.assertNotRegex(source, r"(?im)^\s*(password|pepper)\s*=\s*[\"']", name)
for value in PROXMOX_SECRET.values():
self.assertNotIn(value, source, name)
def test_sources_use_no_direct_network_or_infra_clients(self):
for name in REFERENCE_TITLES:
source = _reference_source(name)
for marker in FORBIDDEN_SOURCE_MARKERS:
self.assertNotIn(marker, source, "%s: %s" % (name, marker))
def test_imports_limited_to_client_module_and_stdlib(self):
for name in REFERENCE_TITLES:
tree = ast.parse(_reference_source(name))
modules = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
modules.update(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom):
modules.add(node.module or "")
for module in modules:
self.assertIn(module, ALLOWED_REFERENCE_IMPORTS, "%s: %s" % (name, module))
def test_owui_frontmatter_docstrings(self):
for name, title in REFERENCE_TITLES.items():
doc = ast.get_docstring(ast.parse(_reference_source(name)))
self.assertTrue(doc.startswith("title: " + title), name)
for line in ("author: coreci", "description: ", "version: 0.1.0",
"requirements: []", "licence: MIT"):
self.assertIn(line, doc, "%s: %s" % (name, line))
def test_tools_surface_valves_state_and_docstring_discipline(self):
for name in REFERENCE_TITLES:
module = _load_reference(name)
tools = module.Tools()
self.assertIsInstance(tools.valves, module.Tools.Valves)
for attr in ("base_url", "agent_id", "app_uuid", "user_id"):
self.assertEqual("", getattr(module.Tools.Valves, attr), attr)
self.assertEqual("", getattr(tools.valves, attr), attr)
self.assertEqual({}, tools._state)
public = []
for node in ast.walk(ast.parse(_reference_source(name))):
if not isinstance(node, ast.FunctionDef):
continue
doc = ast.get_docstring(node)
if node.name.startswith("_"):
self.assertIsNone(doc, "%s.%s" % (name, node.name))
else:
self.assertIsNotNone(doc, "%s.%s" % (name, node.name))
public.append(node.name)
self.assertTrue(public, name)
def test_no_comments_in_sources(self):
for name in REFERENCE_TITLES:
for number, line in enumerate(_reference_source(name).splitlines(), 1):
self.assertFalse(line.lstrip().startswith("#"), "%s:%d" % (name, number))
if __name__ == "__main__":
unittest.main()
+347
View File
@@ -0,0 +1,347 @@
import base64
import contextlib
import datetime
import io
import json
import os
import sys
import tempfile
import unittest
import urllib.error
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"
WHOAMI_PATH = "/api/broker/auth/whoami"
DEFAULT_TTL = 86400
def _basic_header(username, password):
raw = "%s:%s" % (username, password)
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("ascii")
def _seed_direct(broker, app_uuid, user_id):
broker.store.add_row("app_registrations", app_uuid, None)
broker.store.add_row("user_allowlist", user_id, None)
class AgentRegisterTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.tmp = tmp.name
self.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,
}
with contextlib.redirect_stdout(io.StringIO()):
self.broker = coreci_broker.boot(dict(self.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.app_uuid, self.user_id)
def _request(self, method, path, payload=None, headers=None, base=None):
data = None if payload is None else json.dumps(payload).encode("utf-8")
request = urllib.request.Request(
(base or 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:
return response.status, json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
return error.code, json.loads(error.read().decode("utf-8"))
def _seed(self, app_uuid, user_id):
status, _ = self._request("POST", APPS_PATH, {"uuid": app_uuid}, self.admin)
self.assertEqual(201, status)
status, _ = self._request("POST", USERS_PATH, {"user_id": user_id}, self.admin)
self.assertEqual(201, status)
def _register(self, agent_id="agent-1", tags=("coreci",), app_uuid=None,
user_id=None, headers=None, payload=None):
if payload is None:
payload = {
"agent_id": agent_id,
"app_registration_uuid": app_uuid or self.app_uuid,
"user_id": user_id or self.user_id,
"tags": list(tags),
}
return self._request("POST", REGISTER_PATH, payload, headers)
def _agent_row(self, agent_id="agent-1", app_uuid=None):
return self.broker.store.get_agent(agent_id, app_uuid or self.app_uuid)
def _audit(self, action=None, result=None):
with open(self.env["BROKER_AUDIT_LOG_PATH"], "r", encoding="utf-8") as handle:
entries = [json.loads(raw) for raw in handle if raw.strip()]
return [
entry for entry in entries
if (action is None or entry["action"] == action)
and (result is None or entry["result"] == result)
]
def _pepper(self):
return self.broker.store.get_config("BROKER_INSTANCE_PEPPER")
def _app_header(self, token, app_uuid=None):
return {"Authorization": "App %s %s" % (app_uuid or self.app_uuid, token)}
def _ttl_delta_seconds(self, body):
expires = datetime.datetime.fromisoformat(body["token_expires_at"])
now = datetime.datetime.now(datetime.timezone.utc)
return (expires - now).total_seconds()
def test_gate_matrix_unknown_and_revoked_uuid_and_user(self):
unknown_uuid = str(uuidlib.uuid4())
status, body = self._register(app_uuid=unknown_uuid)
self.assertEqual(403, status)
self.assertEqual(403, body["error"])
self.assertTrue(body["message"])
self._request("DELETE", APPS_PATH + "/" + self.app_uuid, headers=self.admin)
status, body = self._register()
self.assertEqual(403, status)
self._request("POST", APPS_PATH + "/" + self.app_uuid + "/reinstate", headers=self.admin)
status, body = self._register(user_id="stranger")
self.assertEqual(403, status)
self._request("DELETE", USERS_PATH + "/" + self.user_id, headers=self.admin)
status, body = self._register()
self.assertEqual(403, status)
denied = self._audit("register", "denied")
self.assertEqual(4, len(denied))
for entry in denied:
self.assertEqual(REGISTER_PATH, entry["path"])
self.assertEqual("agent-1", entry["agent_id"])
self.assertEqual(unknown_uuid, denied[0]["app_registration_uuid"])
self.assertEqual(self.user_id, denied[0]["user_id"])
self.assertEqual(self.app_uuid, denied[1]["app_registration_uuid"])
self.assertIsNone(self._agent_row())
self._request("POST", USERS_PATH + "/" + self.user_id + "/reinstate", headers=self.admin)
status, body = self._register()
self.assertEqual(201, status)
self.assertTrue(body["created"])
self.assertIsNotNone(self._agent_row())
def test_malformed_bodies_are_400(self):
base = {"agent_id": "a", "app_registration_uuid": self.app_uuid,
"user_id": self.user_id, "tags": ["x"]}
variants = (
{}, {"agent_id": "a"}, {"agent_id": "a", "app_registration_uuid": self.app_uuid},
dict(base, agent_id=None), dict(base, agent_id=123), dict(base, agent_id=""),
dict(base, app_registration_uuid=None), dict(base, app_registration_uuid=[]),
dict(base, user_id=7), dict(base, user_id=""), dict(base, tags=None),
dict(base, tags="x"), dict(base, tags=["a", 1]), dict(base, tags=[["nested"]]),
dict(base, tags={"a": 1}), [1, 2, 3], "nope",
)
for payload in variants:
status, body = self._request("POST", REGISTER_PATH, payload)
self.assertEqual(400, status, repr(payload))
self.assertEqual(400, body["error"])
self.assertTrue(body["message"])
self.assertIsNone(self._agent_row())
self.assertEqual([], self._audit("register"))
def test_first_register_creates_row_pepper_and_default_ttl(self):
status, body = self._register(agent_id="px-1", tags=("proxmox", "readonly"))
self.assertEqual(201, status)
self.assertTrue(body["created"])
self.assertEqual(self._pepper(), body["pepper"])
expected = coreci_broker.derive_token(
self._pepper(), self.app_uuid, self.user_id, "px-1", ["proxmox", "readonly"]
)
self.assertEqual(expected, body["token"])
self.assertEqual(44, len(body["token"]))
self.assertTrue(body["token"].isascii())
delta = self._ttl_delta_seconds(body)
self.assertGreater(delta, DEFAULT_TTL - 300)
self.assertLess(delta, DEFAULT_TTL + 300)
row = self._agent_row("px-1")
self.assertNotEqual(body["token"], row[4])
self.assertTrue(coreci_broker.verify_token(body["token"], row[4]))
self.assertEqual(self.user_id, row[2])
self.assertEqual(["proxmox", "readonly"], json.loads(row[3]))
self.assertEqual(body["token_expires_at"], row[5])
self.assertTrue(row[6])
self.assertTrue(row[7])
entries = self._audit("register", "ok")
self.assertEqual(1, len(entries))
entry = entries[0]
self.assertEqual(self.user_id, entry["user_id"])
self.assertEqual(self.app_uuid, entry["app_registration_uuid"])
self.assertEqual("px-1", entry["agent_id"])
self.assertEqual(REGISTER_PATH, entry["path"])
def test_reregister_unchanged_reissues_same_token_without_rehash(self):
_, first = self._register()
stored_hash = self._agent_row()[4]
seen_at = self._agent_row()[7]
status, second = self._register()
self.assertEqual(200, status)
self.assertFalse(second["created"])
self.assertEqual(first["token"], second["token"])
self.assertEqual(first["token_expires_at"], second["token_expires_at"])
row = self._agent_row()
self.assertEqual(stored_hash, row[4])
self.assertGreaterEqual(row[7], seen_at)
self.assertTrue(coreci_broker.verify_token(first["token"], row[4]))
self.assertEqual(2, len(self._audit("register", "ok")))
def test_reregister_pepper_rule_tokenless_vs_valid_app_token(self):
_, first = self._register()
status, tokenless = self._register()
self.assertEqual(200, status)
self.assertEqual(first["token"], tokenless["token"])
self.assertIn("pepper", tokenless)
status, with_token = self._register(headers=self._app_header(first["token"]))
self.assertEqual(200, status)
self.assertNotIn("pepper", with_token)
self.assertEqual(first["token"], with_token["token"])
status, bad_token = self._register(
headers={"Authorization": "App %s wrong-token" % self.app_uuid}
)
self.assertEqual(200, status)
self.assertIn("pepper", bad_token)
def test_changed_tags_overwrite_hash_token_and_expiry(self):
_, first = self._register(tags=("red",))
status, second = self._register(tags=("red", "blue"))
self.assertEqual(200, status)
self.assertFalse(second["created"])
self.assertNotEqual(first["token"], second["token"])
row = self._agent_row()
self.assertFalse(coreci_broker.verify_token(first["token"], row[4]))
self.assertTrue(coreci_broker.verify_token(second["token"], row[4]))
self.assertEqual(["blue", "red"], json.loads(row[3]))
self.assertEqual(second["token_expires_at"], row[5])
def test_changed_user_overwrites_row_identity(self):
self._request("POST", USERS_PATH, {"user_id": "coo"}, self.admin)
_, first = self._register()
status, second = self._register(user_id="coo")
self.assertEqual(200, status)
self.assertNotEqual(first["token"], second["token"])
row = self._agent_row()
self.assertEqual("coo", row[2])
self.assertFalse(coreci_broker.verify_token(first["token"], row[4]))
self.assertTrue(coreci_broker.verify_token(second["token"], row[4]))
def test_expired_row_reregister_rehashes_and_extends(self):
_, first = self._register()
row = self._agent_row()
old_hash, first_registered = row[4], row[6]
past = (datetime.datetime.now(datetime.timezone.utc)
- datetime.timedelta(seconds=60)).isoformat()
self.broker.store.conn.execute(
"UPDATE agent_registrations SET token_expires_at = ? WHERE agent_id = ?",
(past, "agent-1"),
)
self.broker.store.conn.commit()
status, second = self._register()
self.assertEqual(200, status)
self.assertFalse(second["created"])
self.assertEqual(first["token"], second["token"])
delta = self._ttl_delta_seconds(second)
self.assertGreater(delta, DEFAULT_TTL - 300)
row = self._agent_row()
self.assertNotEqual(old_hash, row[4])
self.assertEqual(first_registered, row[6])
self.assertTrue(coreci_broker.verify_token(first["token"], row[4]))
self.assertEqual(second["token_expires_at"], row[5])
self.assertIn("pepper", second)
def test_revoked_app_blocks_new_registrations_rows_untouched(self):
_, first = self._register()
row_before = self._agent_row()
self._request("DELETE", APPS_PATH + "/" + self.app_uuid, headers=self.admin)
status, body = self._register(agent_id="fresh-agent")
self.assertEqual(403, status)
status, body = self._register()
self.assertEqual(403, status)
self.assertEqual(2, len(self._audit("register", "denied")))
row_after = self._agent_row()
self.assertEqual(row_before[4], row_after[4])
self.assertEqual(row_before[5], row_after[5])
self.assertTrue(coreci_broker.verify_token(first["token"], row_after[4]))
def test_token_ttl_override_honored(self):
env = dict(self.env)
env["BROKER_DB_PATH"] = os.path.join(self.tmp, "ttl.db")
env["BROKER_AUDIT_LOG_PATH"] = os.path.join(self.tmp, "ttl.audit.log")
env["BROKER_TOKEN_TTL"] = "60"
with contextlib.redirect_stdout(io.StringIO()):
broker = coreci_broker.boot(dict(env))
self.addCleanup(broker.audit.close)
self.addCleanup(broker.store.close)
_seed_direct(broker, self.app_uuid, self.user_id)
server = coreci_broker.serve("127.0.0.1", 0, broker)
self.addCleanup(server.shutdown)
self.addCleanup(server.server_close)
base = "http://127.0.0.1:%d" % server.server_address[1]
payload = {"agent_id": "ttl-agent", "app_registration_uuid": self.app_uuid,
"user_id": self.user_id, "tags": []}
status, body = self._request("POST", REGISTER_PATH, payload, base=base)
self.assertEqual(201, status)
delta = self._ttl_delta_seconds(body)
self.assertGreater(delta, 30)
self.assertLess(delta, 90)
status, body = self._request("POST", REGISTER_PATH, payload, base=base)
self.assertEqual(200, status)
delta = self._ttl_delta_seconds(body)
self.assertGreater(delta, 30)
self.assertLess(delta, 90)
def test_user_allowlist_rejects_slash_backslash_and_control_chars(self):
for bad in ("a/b", "a\\b", "a\nb", "a\x00b", "a\x1fb", "a\x7fb"):
status, body = self._request("POST", USERS_PATH, {"user_id": bad}, self.admin)
self.assertEqual(400, status, repr(bad))
self.assertEqual({"error": 400, "message": "invalid user_id"}, body)
status, body = self._request("GET", USERS_PATH, headers=self.admin)
self.assertEqual([self.user_id], [row["user_id"] for row in body["allowlist"]])
def test_bearer_scheme_with_valid_creds_is_401(self):
header = "Bearer " + base64.b64encode(
("%s:%s" % (ADMIN_USER, ADMIN_PASS)).encode("utf-8")
).decode("ascii")
status, body = self._request("GET", WHOAMI_PATH, headers={"Authorization": header})
self.assertEqual(401, status)
self.assertEqual(401, body["error"])
def test_basic_without_colon_is_401(self):
header = "Basic " + base64.b64encode(b"just-a-username").decode("ascii")
status, body = self._request("GET", WHOAMI_PATH, headers={"Authorization": header})
self.assertEqual(401, status)
self.assertEqual(401, body["error"])
def test_basic_garbage_rejected_lowercase_scheme_accepted(self):
status, body = self._request(
"GET", WHOAMI_PATH, headers={"Authorization": "Basic !!!not-base64!!!"}
)
self.assertEqual(401, status)
header = _basic_header(ADMIN_USER, ADMIN_PASS).replace("Basic", "basic")
status, body = self._request("GET", WHOAMI_PATH, headers={"Authorization": header})
self.assertEqual(200, status)
self.assertEqual(ADMIN_USER, body["username"])
if __name__ == "__main__":
unittest.main()
+233
View File
@@ -0,0 +1,233 @@
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()
+318
View File
@@ -0,0 +1,318 @@
import base64
import contextlib
import io
import json
import os
import sys
import tempfile
import threading
import unittest
import urllib.error
import urllib.request
import uuid as uuidlib
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
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"
STATE_PATH = "/api/broker/agents/%s/state"
STUB_PREFIX = "/api/v1/broker-agent-state/"
def _basic_header(username, password):
raw = "%s:%s" % (username, password)
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("ascii")
class _StubOwuiHandler(BaseHTTPRequestHandler):
documents = None
records = None
def log_message(self, format, *args):
pass
def do_GET(self):
agent_id = self.path[len(STUB_PREFIX):]
self.records.append(("GET", self.path, self.headers.get("Authorization"), None))
if agent_id not in self.documents:
self._send(404, {"error": 404})
return
self._send(200, self.documents[agent_id])
def do_PUT(self):
agent_id = self.path[len(STUB_PREFIX):]
length = int(self.headers.get("Content-Length") or "0")
raw = self.rfile.read(length)
self.records.append(("PUT", self.path, self.headers.get("Authorization"), raw))
self.documents[agent_id] = json.loads(raw.decode("utf-8"))
self._send(200, {"ok": True})
def _send(self, status, payload):
data = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
class StubOwuiServer:
def __init__(self):
self.documents, self.records = {}, []
handler = type("BoundStubHandler", (_StubOwuiHandler,),
{"documents": self.documents, "records": self.records})
self.server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
threading.Thread(target=self.server.serve_forever, daemon=True).start()
@property
def base_url(self):
return "http://127.0.0.1:%d" % self.server.server_address[1]
def close(self):
self.server.shutdown()
self.server.server_close()
class _StateCase(unittest.TestCase):
backend = "internal"
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.tmp = tmp.name
self.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_AGENT_STATE_BACKEND": self.backend,
}
self.env.update(getattr(self, "extra_env", {}))
self._start_broker()
self.admin = {"Authorization": _basic_header(ADMIN_USER, ADMIN_PASS)}
self.app_uuid = str(uuidlib.uuid4())
self.user_id = "ceo"
self._seed(self.app_uuid, self.user_id)
self.registered = self._register("agent-1", ["b", "a"])
self.app = {"Authorization": "App %s %s" % (self.app_uuid, self.registered["token"])}
def _start_broker(self):
with contextlib.redirect_stdout(io.StringIO()):
self.broker = coreci_broker.boot(dict(self.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]
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 _state(self, method, agent_id, payload=None, headers=None):
return self._request(method, STATE_PATH % agent_id, payload,
self.app if headers is None else headers)
def _seed(self, app_uuid, user_id):
status, _ = self._request("POST", APPS_PATH, {"uuid": app_uuid}, self.admin)
self.assertEqual(201, status)
status, _ = self._request("POST", USERS_PATH, {"user_id": 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
class InternalStateTests(_StateCase):
def test_put_then_get_roundtrip_is_document_faithful(self):
state = {"agent_id": "agent-1", "app_registration_uuid": self.app_uuid,
"user_id": self.user_id, "tags": ["b", "a"],
"pepper": self.registered["pepper"],
"current_token": self.registered["token"],
"token_expires_at": self.registered["token_expires_at"],
"first_registered_at": "t0", "last_seen_at": "t1",
"nested": {"flags": [1, 2.5, None, True], "deep": {"k": None}}, "z": 1}
status, body = self._state("PUT", "agent-1", state)
self.assertEqual(200, status)
self.assertEqual({"ok": True}, body)
status, body = self._state("GET", "agent-1")
self.assertEqual(200, status)
self.assertEqual(state, body)
self.assertEqual(list(state), list(body))
self.assertEqual(state, self.broker.store.get_agent_state("agent-1", self.app_uuid))
def test_state_survives_broker_restart(self):
state = {"counter": 7, "notes": ["x"], "pepper": "keep-me"}
status, _ = self._state("PUT", "agent-1", state)
self.assertEqual(200, status)
self.server.shutdown()
self.server.server_close()
self.broker.store.close()
with contextlib.redirect_stdout(io.StringIO()):
second = coreci_broker.boot(dict(self.env))
self.addCleanup(second.audit.close)
self.addCleanup(second.store.close)
server = coreci_broker.serve("127.0.0.1", 0, second)
self.addCleanup(server.shutdown)
self.addCleanup(server.server_close)
self.base = "http://127.0.0.1:%d" % server.server_address[1]
status, body = self._state("GET", "agent-1")
self.assertEqual(200, status)
self.assertEqual(state, body)
row = second.store.get_agent("agent-1", self.app_uuid)
self.assertTrue(coreci_broker.verify_token(self.registered["token"], row[4]))
def test_identity_mismatch_is_403_on_get_and_put(self):
second = self._register("agent-2", ["coreci"])
for method, payload in (("GET", None), ("PUT", {"who": "agent-2"})):
status, body = self._state(method, "agent-2", payload)
self.assertEqual(403, status, method)
self.assertEqual(403, body["error"])
self.assertTrue(body["message"])
status, body = self._state("GET", "agent-2", headers={
"Authorization": "App %s %s" % (self.app_uuid, second["token"])})
self.assertEqual(404, status)
def test_unregistered_agent_id_is_404_on_get(self):
status, body = self._state("GET", "ghost-agent")
self.assertEqual(404, status)
self.assertEqual({"error": 404, "message": "not found"}, body)
def test_get_without_state_is_404(self):
status, body = self._state("GET", "agent-1")
self.assertEqual(404, status)
self.assertEqual({"error": 404, "message": "not found"}, body)
def test_put_non_dict_is_400(self):
for payload in ([1, 2], "nope", 5, None):
status, body = self._state("PUT", "agent-1", payload)
self.assertEqual(400, status, repr(payload))
self.assertEqual(400, body["error"])
status, _ = self._state("GET", "agent-1")
self.assertEqual(404, status)
def test_put_above_256kib_is_400(self):
status, body = self._state("PUT", "agent-1", {"pad": "x" * (300 * 1024)})
self.assertEqual(400, status)
self.assertEqual(400, body["error"])
status, _ = self._state("GET", "agent-1")
self.assertEqual(404, status)
def test_put_at_exactly_256kib_is_stored(self):
pad = "x" * (256 * 1024 - len(json.dumps({"pad": ""})))
status, body = self._state("PUT", "agent-1", {"pad": pad})
self.assertEqual(200, status)
status, body = self._state("GET", "agent-1")
self.assertEqual(200, status)
self.assertEqual({"pad": pad}, body)
def test_missing_app_header_is_401(self):
status, body = self._state("GET", "agent-1", headers={})
self.assertEqual(401, status)
self.assertEqual({"error": 401, "message": "invalid app credentials"}, body)
class OwuiStateTests(_StateCase):
backend = "owui"
def setUp(self):
self.stub = StubOwuiServer()
self.addCleanup(self.stub.close)
self.extra_env = {
"BROKER_OWUI_BASE_URL": self.stub.base_url,
"BROKER_OWUI_API_KEY": "test-key",
}
super().setUp()
def test_put_then_get_roundtrip_flows_through_stub(self):
state = {"agent_id": "agent-1", "current_token": "tok", "n": 3}
status, body = self._state("PUT", "agent-1", state)
self.assertEqual(200, status)
self.assertEqual({"ok": True}, body)
self.assertEqual(state, self.stub.documents["agent-1"])
status, body = self._state("GET", "agent-1")
self.assertEqual(200, status)
self.assertEqual(state, body)
puts = [record for record in self.stub.records if record[0] == "PUT"]
gets = [record for record in self.stub.records if record[0] == "GET"]
self.assertEqual(1, len(puts))
self.assertEqual(1, len(gets))
for _, path, auth, _ in self.stub.records:
self.assertEqual("Bearer test-key", auth)
self.assertEqual(STUB_PREFIX + "agent-1", puts[0][1])
self.assertEqual(STUB_PREFIX + "agent-1", gets[0][1])
self.assertEqual(state, json.loads(puts[0][3].decode("utf-8")))
def test_stub_miss_maps_to_broker_404(self):
status, body = self._state("GET", "agent-1")
self.assertEqual(404, status)
self.assertEqual({"error": 404, "message": "not found"}, body)
def test_identity_enforced_before_backend_call(self):
self._register("agent-2", ["coreci"])
status, body = self._state("GET", "agent-2")
self.assertEqual(403, status)
status, body = self._state("PUT", "agent-2", {"x": 1})
self.assertEqual(403, status)
self.assertEqual([], self.stub.records)
class BackendToggleTests(unittest.TestCase):
def _boot(self, extra):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
env = {
"BROKER_ENV_PATH": os.path.join(tmp.name, ".env"),
"BROKER_DB_PATH": os.path.join(tmp.name, "broker.db"),
"BROKER_AUDIT_LOG_PATH": os.path.join(tmp.name, "broker.audit.log"),
}
env.update(extra)
with contextlib.redirect_stdout(io.StringIO()):
broker = coreci_broker.boot(env)
self.addCleanup(broker.audit.close)
self.addCleanup(broker.store.close)
return broker
def test_default_backend_is_internal(self):
broker = self._boot({})
self.assertIsInstance(broker.agent_state_backend, coreci_broker.InternalAgentStateBackend)
def test_owui_backend_selected_by_config(self):
broker = self._boot({
"BROKER_AGENT_STATE_BACKEND": "owui",
"BROKER_OWUI_BASE_URL": "http://127.0.0.1:9999/",
"BROKER_OWUI_API_KEY": "k",
})
backend = broker.agent_state_backend
self.assertIsInstance(backend, coreci_broker.OwuiAgentStateBackend)
self.assertEqual("http://127.0.0.1:9999", backend.base_url)
self.assertEqual("k", backend.api_key)
def test_unknown_backend_value_falls_back_to_internal(self):
broker = self._boot({"BROKER_AGENT_STATE_BACKEND": "banana"})
self.assertIsInstance(broker.agent_state_backend, coreci_broker.InternalAgentStateBackend)
if __name__ == "__main__":
unittest.main()
+67
View File
@@ -110,5 +110,72 @@ class StaticDisclosureGuardTests(unittest.TestCase):
os.chdir(cwd)
class CredsFileDisclosureGuardTests(unittest.TestCase):
SECRET_JSON = json.dumps({"coreci/proxmox": {"token": "pve-tok"}})
def _get(self, base, path):
try:
with urllib.request.urlopen(base + path, timeout=5) as response:
return response.status, response.read()
except urllib.error.HTTPError as error:
return error.code, error.read()
def _write_creds(self, path):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as handle:
handle.write(self.SECRET_JSON)
def test_default_layout_never_serves_creds_file(self):
cwd = os.getcwd()
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
creds_path = os.path.join(tmp.name, "data", "creds.json")
self._write_creds(creds_path)
os.chdir(tmp.name)
try:
with contextlib.redirect_stdout(io.StringIO()):
broker = coreci_broker.boot({"BROKER_ENV_PATH": os.path.join(tmp.name, ".env")})
server = coreci_broker.serve("127.0.0.1", 0, broker)
base = "http://127.0.0.1:%d" % server.server_address[1]
for path in ("/static/data/creds.json", "/static/data%2Fcreds.json"):
with self.subTest(path=path):
status, body = self._get(base, path)
self.assertEqual(404, status)
self.assertNotIn(b"pve-tok", body)
server.shutdown()
server.server_close()
broker.audit.close()
broker.store.close()
finally:
os.chdir(cwd)
def test_creds_file_blocked_when_roots_overlap_creds_dir(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
creds_path = os.path.join(tmp.name, "creds.json")
self._write_creds(creds_path)
env = {
"BROKER_ENV_PATH": os.path.join(tmp.name, ".env"),
"BROKER_DB_PATH": os.path.join(tmp.name, "broker.db"),
"BROKER_AUDIT_LOG_PATH": os.path.join(tmp.name, "broker.audit.log"),
"BROKER_UI_ROOT": tmp.name,
"BROKER_STATIC_ROOT": tmp.name,
"BROKER_CREDS_FILE": creds_path,
}
with contextlib.redirect_stdout(io.StringIO()):
broker = coreci_broker.boot(dict(env))
self.addCleanup(broker.audit.close)
self.addCleanup(broker.store.close)
server = coreci_broker.serve("127.0.0.1", 0, broker)
self.addCleanup(server.server_close)
self.addCleanup(server.shutdown)
base = "http://127.0.0.1:%d" % server.server_address[1]
for path in ("/static/creds.json", "/ui/creds.json"):
with self.subTest(path=path):
status, body = self._get(base, path)
self.assertEqual(404, status)
self.assertNotIn(b"pve-tok", body)
if __name__ == "__main__":
unittest.main()