feat(P01): SLICE-05 operator bootstrap CLI + secrets scope
- 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---
This commit is contained in:
@@ -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
|
||||||
@@ -99,6 +99,10 @@
|
|||||||
{
|
{
|
||||||
"name": "voice",
|
"name": "voice",
|
||||||
"env_vars": ["DEEPGRAM_API_KEY", "CARTESIA_API_KEY", "OLLAMA_API_KEY"]
|
"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"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ venv/
|
|||||||
.env.secrets
|
.env.secrets
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
!.env.secrets.example
|
||||||
|
!.ciagent/.env.secrets.example
|
||||||
|
|
||||||
# SQLite
|
# SQLite
|
||||||
*.db
|
*.db
|
||||||
|
|||||||
Executable
+106
@@ -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())
|
||||||
@@ -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$")
|
||||||
Reference in New Issue
Block a user