From e8a05adcd1cfe87e6989a2d3f126ef0a7a1e7163 Mon Sep 17 00:00:00 2001 From: Praxis CI Date: Tue, 4 Aug 2026 00:56:41 +0000 Subject: [PATCH] feat(P01): SLICE-05 operator bootstrap CLI + secrets scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TASK-05-01 scripts/create-operator.py: CLI that reads PRAXIS_BOOTSTRAP_OPERATOR_USER/PASS + PRAXIS_PG_DSN from env, creates the pool, applies migrations, hashes the password with argon2id, and INSERTs with ON CONFLICT DO NOTHING (idempotent — D-052). --update flag forces rehash + ON CONFLICT DO UPDATE. Missing env → exit 1 (R-BOOT-02). Connection failure → 3x retry with 5s backoff (R-BOOT-01). - TASK-05-02 .ciagent/config.json: added "operator" secrets scope (PRAXIS_PG_PASSWORD, PRAXIS_COOKIE_SECRET, PRAXIS_BOOTSTRAP_OPERATOR_USER/PASS, PRAXIS_VC_ISSUER_KEY). .ciagent/.env.secrets.example: template (committed, no real secrets). .gitignore: added negations so .env.secrets.example is tracked while .env.secrets stays ignored. - TASK-05-03 tests/test_create_operator.py: 7 tests (mocked PgStore) — create, already-exists (no update), --update rehashes, missing env → exit 1, password is argon2id (not plaintext). ---ci--- project: praxis phase: 1 milestone: v0.4 status: execute persona: devops-engineer task: 05-01,05-02,05-03 requirements: covered: [REQ-AUTH-01] ---/ci--- --- .ciagent/.env.secrets.example | 29 +++++ .ciagent/config.json | 4 + .gitignore | 2 + scripts/create-operator.py | 106 +++++++++++++++++ tests/test_create_operator.py | 217 ++++++++++++++++++++++++++++++++++ 5 files changed, 358 insertions(+) create mode 100644 .ciagent/.env.secrets.example create mode 100755 scripts/create-operator.py create mode 100644 tests/test_create_operator.py diff --git a/.ciagent/.env.secrets.example b/.ciagent/.env.secrets.example new file mode 100644 index 0000000..2a88a8e --- /dev/null +++ b/.ciagent/.env.secrets.example @@ -0,0 +1,29 @@ +# Praxis — Operator-tier secrets template (v0.4, TASK-05-02). +# Copy to .ciagent/.env.secrets and fill in real values. +# .env.secrets is gitignored (verified in .gitignore: .env.secrets). +# This file (.env.secrets.example) is committed as documentation. + +# ─── Operator tier (v0.4) ─────────────────────────────────────────────────── +# Postgres password. Generate: openssl rand -base64 32 +PRAXIS_PG_PASSWORD= + +# Full Postgres DSN. host=postgres is the docker-compose service DNS name. +# postgresql://praxis:${PRAXIS_PG_PASSWORD}@postgres:5432/praxis +PRAXIS_PG_DSN= + +# Cookie signing secret (>=32 bytes). Generate: openssl rand -base64 48 +PRAXIS_COOKIE_SECRET= + +# Bootstrap operator credentials (scripts/create-operator.py). +PRAXIS_BOOTSTRAP_OPERATOR_USER= +PRAXIS_BOOTSTRAP_OPERATOR_PASS= + +# VC issuer root key (nacl.SecretBox, 32 bytes). Generate: +# python3 -c "import nacl.utils; print(nacl.utils.random(32).hex())" +PRAXIS_VC_ISSUER_KEY= + +# Issuer URL (public base for VC identifiers). +PRAXIS_ISSUER_URL=https://praxis.example/issuers/v0.4 + +# Cookie Secure flag — set false ONLY for the HTTP pilot (R-AUTH-01, G-031). +PRAXIS_COOKIE_SECURE=true \ No newline at end of file diff --git a/.ciagent/config.json b/.ciagent/config.json index d1317cf..94251e0 100644 --- a/.ciagent/config.json +++ b/.ciagent/config.json @@ -99,6 +99,10 @@ { "name": "voice", "env_vars": ["DEEPGRAM_API_KEY", "CARTESIA_API_KEY", "OLLAMA_API_KEY"] + }, + { + "name": "operator", + "env_vars": ["PRAXIS_PG_PASSWORD", "PRAXIS_COOKIE_SECRET", "PRAXIS_BOOTSTRAP_OPERATOR_USER", "PRAXIS_BOOTSTRAP_OPERATOR_PASS", "PRAXIS_VC_ISSUER_KEY"] } ] }, diff --git a/.gitignore b/.gitignore index 0510b7d..769f816 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,8 @@ venv/ .env.secrets .env.* !.env.example +!.env.secrets.example +!.ciagent/.env.secrets.example # SQLite *.db diff --git a/scripts/create-operator.py b/scripts/create-operator.py new file mode 100755 index 0000000..376eb24 --- /dev/null +++ b/scripts/create-operator.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Praxis v0.4 — Operator bootstrap CLI (TASK-05-01, D-052). + +Creates the initial operator from env-provided credentials. Idempotent +(ON CONFLICT DO NOTHING). The --update flag forces a rehash + update. + +Env: + PRAXIS_BOOTSTRAP_OPERATOR_USER — operator username (required) + PRAXIS_BOOTSTRAP_OPERATOR_PASS — operator password (required) + PRAXIS_PG_DSN — Postgres DSN (required) + +Exit: 0 on success (created or already-exists), 1 on missing env / DB error. +Retries on connection failure (3 attempts, 5s backoff — R-BOOT-01). + +Run: + PRAXIS_BOOTSTRAP_OPERATOR_USER=admin PRAXIS_BOOTSTRAP_OPERATOR_PASS=... \ + PRAXIS_PG_DSN=postgresql://praxis:...@postgres:5432/praxis \ + python3 scripts/create-operator.py +""" + +from __future__ import annotations + +import argparse +import asyncio +import os +import sys + +from argon2 import PasswordHasher + +_ph = PasswordHasher() +_RETRY_ATTEMPTS = 3 +_RETRY_BACKOFF_S = 5.0 + + +async def create_operator(update: bool = False) -> int: + user = os.environ.get("PRAXIS_BOOTSTRAP_OPERATOR_USER", "").strip() + pw = os.environ.get("PRAXIS_BOOTSTRAP_OPERATOR_PASS", "") + dsn = os.environ.get("PRAXIS_PG_DSN", "").strip() + if not user or not pw: + print( + "create-operator: ERROR — PRAXIS_BOOTSTRAP_OPERATOR_USER and " + "PRAXIS_BOOTSTRAP_OPERATOR_PASS must be set (R-BOOT-02).", + file=sys.stderr, + ) + return 1 + if not dsn: + print( + "create-operator: ERROR — PRAXIS_PG_DSN must be set.", + file=sys.stderr, + ) + return 1 + + import asyncpg + from db.pg_migrate import apply_pg_migrations + from db.pg_store import PgStore + + last_exc: Exception | None = None + for attempt in range(1, _RETRY_ATTEMPTS + 1): + try: + pool = await asyncpg.create_pool( + dsn=dsn, min_size=1, max_size=3, command_timeout=10 + ) + try: + await apply_pg_migrations(pool) + store = PgStore(pool) + pw_hash = _ph.hash(pw) + display = user + oid = await store.insert_operator( + user, pw_hash, display, on_conflict_update=update + ) + if update: + print(f"create-operator: updated operator {user!r} (id={oid})") + elif oid is not None: + print(f"create-operator: created operator {user!r} (id={oid})") + else: + print(f"create-operator: operator {user!r} already exists (no change)") + return 0 + finally: + await pool.close() + except (asyncpg.PostgresConnectionError, ConnectionError, OSError) as exc: + last_exc = exc + if attempt < _RETRY_ATTEMPTS: + print( + f"create-operator: connection attempt {attempt} failed " + f"({exc}); retrying in {_RETRY_BACKOFF_S}s (R-BOOT-01)...", + file=sys.stderr, + ) + await asyncio.sleep(_RETRY_BACKOFF_S) + continue + print(f"create-operator: ERROR — could not connect after {_RETRY_ATTEMPTS} " + f"attempts: {last_exc}", file=sys.stderr) + return 1 + + +def main() -> int: + parser = argparse.ArgumentParser(description="Create the initial Praxis operator.") + parser.add_argument( + "--update", action="store_true", + help="Force rehash + update if the operator already exists.", + ) + args = parser.parse_args() + return asyncio.run(create_operator(update=args.update)) + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/tests/test_create_operator.py b/tests/test_create_operator.py new file mode 100644 index 0000000..5e7beef --- /dev/null +++ b/tests/test_create_operator.py @@ -0,0 +1,217 @@ +"""Bootstrap CLI test (TASK-05-03) — mocked PgStore, no real Postgres. + +Covers: create operator → exists; re-run → "already exists" (no update); +--update → password updated; missing env → exit 1; password is argon2id +(not plaintext). +""" + +from __future__ import annotations + +import importlib +import os +import sys +from unittest.mock import AsyncMock, MagicMock + +import pytest + + +def _load_cli_module(monkeypatch, env: dict, update: bool = False): + """Load scripts/create-operator.py as a module with a mocked asyncpg pool.""" + for k in ("PRAXIS_BOOTSTRAP_OPERATOR_USER", "PRAXIS_BOOTSTRAP_OPERATOR_PASS", + "PRAXIS_PG_DSN"): + monkeypatch.delenv(k, raising=False) + for k, v in env.items(): + if v is None: + monkeypatch.delenv(k, raising=False) + else: + monkeypatch.setenv(k, v) + # Import the script as a module by path. + import importlib.util + spec = importlib.util.spec_from_file_location( + "create_operator", "scripts/create-operator.py" + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def _make_mock_pool_store(operators: dict[str, dict] | None = None): + operators = operators if operators is not None else {} + + pool = MagicMock() + pool.close = AsyncMock() + + conn = MagicMock() + + async def acquire_ctx(): + return conn + + cm = MagicMock() + cm.__aenter__ = AsyncMock(return_value=conn) + cm.__aexit__ = AsyncMock(return_value=None) + pool.acquire = MagicMock(return_value=cm) + + store = MagicMock() + + async def insert_operator(username, password_hash, display_name, *, on_conflict_update=False): + if on_conflict_update: + operators[username] = { + "id": "11111111-1111-1111-1111-111111111111", + "username": username, + "password_hash": password_hash, + } + return operators[username]["id"] + if username in operators: + return None # already exists + operators[username] = { + "id": "11111111-1111-1111-1111-111111111111", + "username": username, + "password_hash": password_hash, + } + return operators[username]["id"] + + store.insert_operator = insert_operator + return pool, store, operators + + +@pytest.mark.asyncio +async def test_create_operator_creates(monkeypatch, capsys): + env = { + "PRAXIS_BOOTSTRAP_OPERATOR_USER": "admin", + "PRAXIS_BOOTSTRAP_OPERATOR_PASS": "secret-pw", + "PRAXIS_PG_DSN": "postgresql://praxis:x@localhost/praxis", + } + mod = _load_cli_module(monkeypatch, env) + pool, store, operators = _make_mock_pool_store() + + import asyncpg + async def fake_create_pool(**kw): + return pool + monkeypatch.setattr(asyncpg, "create_pool", fake_create_pool) + + from db.pg_migrate import apply_pg_migrations as _apm + import db.pg_migrate + async def fake_apply_migrations(p): + return ["0001_operator_tier"] + monkeypatch.setattr(db.pg_migrate, "apply_pg_migrations", fake_apply_migrations) + + import db.pg_store + monkeypatch.setattr(db.pg_store, "PgStore", lambda p: store) + + rc = await mod.create_operator(update=False) + assert rc == 0 + out = capsys.readouterr().out + assert "created" in out + assert "admin" in operators + h = operators["admin"]["password_hash"] + assert h.startswith("$argon2id$") + assert "secret-pw" not in h # not plaintext + + +@pytest.mark.asyncio +async def test_create_operator_already_exists(monkeypatch, capsys): + env = { + "PRAXIS_BOOTSTRAP_OPERATOR_USER": "admin", + "PRAXIS_BOOTSTRAP_OPERATOR_PASS": "secret-pw", + "PRAXIS_PG_DSN": "postgresql://praxis:x@localhost/praxis", + } + mod = _load_cli_module(monkeypatch, env) + pool, store, operators = _make_mock_pool_store({"admin": { + "id": "id1", "username": "admin", "password_hash": "$argon2id$old" + }}) + + import asyncpg + async def fake_create_pool(**kw): + return pool + monkeypatch.setattr(asyncpg, "create_pool", fake_create_pool) + + import db.pg_migrate + async def fake_apply_migrations(p): + return [] + monkeypatch.setattr(db.pg_migrate, "apply_pg_migrations", fake_apply_migrations) + + import db.pg_store + monkeypatch.setattr(db.pg_store, "PgStore", lambda p: store) + + rc = await mod.create_operator(update=False) + assert rc == 0 + out = capsys.readouterr().out + assert "already exists" in out + # password NOT updated + assert operators["admin"]["password_hash"] == "$argon2id$old" + + +@pytest.mark.asyncio +async def test_create_operator_update_rehashes(monkeypatch, capsys): + env = { + "PRAXIS_BOOTSTRAP_OPERATOR_USER": "admin", + "PRAXIS_BOOTSTRAP_OPERATOR_PASS": "new-pw", + "PRAXIS_PG_DSN": "postgresql://praxis:x@localhost/praxis", + } + mod = _load_cli_module(monkeypatch, env) + pool, store, operators = _make_mock_pool_store({"admin": { + "id": "id1", "username": "admin", "password_hash": "$argon2id$old" + }}) + + import asyncpg + async def fake_create_pool(**kw): + return pool + monkeypatch.setattr(asyncpg, "create_pool", fake_create_pool) + + import db.pg_migrate + async def fake_apply_migrations(p): + return [] + monkeypatch.setattr(db.pg_migrate, "apply_pg_migrations", fake_apply_migrations) + + import db.pg_store + monkeypatch.setattr(db.pg_store, "PgStore", lambda p: store) + + rc = await mod.create_operator(update=True) + assert rc == 0 + out = capsys.readouterr().out + assert "updated" in out + assert operators["admin"]["password_hash"].startswith("$argon2id$") + assert operators["admin"]["password_hash"] != "$argon2id$old" + + +@pytest.mark.asyncio +async def test_create_operator_missing_user_env(monkeypatch, capsys): + env = { + "PRAXIS_BOOTSTRAP_OPERATOR_PASS": "x", + "PRAXIS_PG_DSN": "postgresql://praxis:x@localhost/praxis", + } + mod = _load_cli_module(monkeypatch, env) + rc = await mod.create_operator(update=False) + assert rc == 1 + err = capsys.readouterr().err + assert "PRAXIS_BOOTSTRAP_OPERATOR_USER" in err + + +@pytest.mark.asyncio +async def test_create_operator_missing_pass_env(monkeypatch, capsys): + env = { + "PRAXIS_BOOTSTRAP_OPERATOR_USER": "admin", + "PRAXIS_PG_DSN": "postgresql://praxis:x@localhost/praxis", + } + mod = _load_cli_module(monkeypatch, env) + rc = await mod.create_operator(update=False) + assert rc == 1 + + +@pytest.mark.asyncio +async def test_create_operator_missing_dsn(monkeypatch, capsys): + env = { + "PRAXIS_BOOTSTRAP_OPERATOR_USER": "admin", + "PRAXIS_BOOTSTRAP_OPERATOR_PASS": "x", + } + mod = _load_cli_module(monkeypatch, env) + rc = await mod.create_operator(update=False) + assert rc == 1 + err = capsys.readouterr().err + assert "PRAXIS_PG_DSN" in err + + +def test_password_hash_is_argon2id(): + from argon2 import PasswordHasher + h = PasswordHasher().hash("test") + assert h.startswith("$argon2id$") \ No newline at end of file