Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd6892a5bd | |||
| 68d6ba4652 | |||
| 2de11e9724 | |||
| 4e326c309c |
@@ -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
@@ -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 |
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
@@ -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).
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user