Files
UmbraTrans/umbratrans_web/cli.py
umbra audio dda079f605 commit audio ingest: apply 0008+0009 migrations, wire Postgres adapter to commit path
- Apply migration 0008_audio_source_import (additive schema for source import)
- Apply migration 0009_translation_import_runs (ledger table for import runs)
- Wire PostgresAudioImportStore into HTTP commit path with org from node credential
- Commit path uses PG store with org from node, no payload org
- Bundle hash 21ce5687… remains frozen until v2; no authority transferred
2026-10-19 04:35:07 -07:00

656 lines
25 KiB
Python

# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
"""Hidden-prompt operator CLI for the first local administrator.
Installed as the ``umbratrans-bootstrap-admin`` console script and also
reachable as ``python -m umbratrans_web bootstrap-admin``.
Guarantees:
* The one-time password is read only from a TTY hidden prompt
(:func:`getpass.getpass`). It is never read from an argument, an environment
variable, a file, or stdin, and it is never echoed, logged, or audited.
* The command refuses to run when ``UMBRA_ENV`` selects production, and it
writes only to a local development repository with restrictive file
permissions.
* Only the account identifier, role, status, and ``must_change_password`` state
are printed. The command prints nothing that could help recover the secret.
Note on the bootstrap account: the default username is the documented,
non-secret bootstrap identifier ``Umbraadmin``. The password is supplied
interactively by the operator at run time; this repository intentionally
contains no such value.
"""
from __future__ import annotations
import argparse
import getpass
import hmac
import json
import os
import stat
import sys
import tempfile
from collections.abc import Callable, Sequence
from dataclasses import asdict, replace
from datetime import datetime
from pathlib import Path
from typing import Any, Protocol, TextIO
from uuid import UUID, uuid4
from .config import (
DEVELOPMENT_DATA_ROOT,
DEVELOPMENT_REPOSITORY_RELATIVE_PATH,
Settings,
load_settings,
)
from .errors import InputError, OperatorRefusalError, PasswordPolicyError, UmbraWebError
from .models import AccountView, RoleName
from .service import AccountService, OperatorContext
BOOTSTRAP_USERNAME = "Umbraadmin"
BOOTSTRAP_DISPLAY_NAME = "Umbra Administrator"
EXIT_OK = 0
EXIT_ERROR = 1
EXIT_VALIDATION_FAILED = 2
EXIT_REFUSED_PRODUCTION = 3
EXIT_NOT_INTERACTIVE = 4
EXIT_PASSWORD_REJECTED = 5
#: Development-only location for the audio-import ledger. The ledger is the
#: stand-in for the PostgreSQL store until the source-import migration is
#: applied; it is never written in production.
AUDIO_IMPORT_LEDGER_NAME = "audio-import-ledger.json"
LEDGER_DIRECTORY_MODE = 0o700
LEDGER_FILE_MODE = 0o600
class _Tty(Protocol):
def isatty(self) -> bool: ...
class _InteractiveTerminalUnavailable(OperatorRefusalError):
pass
def _default_repository_path() -> Path:
return (DEVELOPMENT_DATA_ROOT / DEVELOPMENT_REPOSITORY_RELATIVE_PATH).resolve()
def _require_interactive(stdin: TextIO) -> None:
"""Refuse to run without a terminal.
A hidden prompt needs a real TTY. Without one, the command fails instead
of falling back to a value that could land in a shell history file.
"""
try:
interactive = bool(stdin.isatty())
except (AttributeError, ValueError, OSError) as error:
raise _InteractiveTerminalUnavailable(
"terminal_unavailable", "A hidden prompt requires an interactive terminal."
) from error
if not interactive:
raise _InteractiveTerminalUnavailable(
"not_interactive",
"A hidden prompt requires an interactive terminal; refusing to read a password from a pipe or file.",
)
def _prompt_hidden_password(prompt: str) -> str:
return getpass.getpass(prompt)
def build_parser() -> argparse.ArgumentParser:
"""Build the argument parser for the bootstrap command."""
parser = argparse.ArgumentParser(
prog="umbratrans-bootstrap-admin",
description=(
"Provision the first local development administrator. The one-time password is read from a hidden "
"prompt and is never stored in plaintext. Production mode is refused."
),
)
parser.add_argument("--username", default=BOOTSTRAP_USERNAME, help="non-secret bootstrap username")
parser.add_argument("--display-name", default=BOOTSTRAP_DISPLAY_NAME, help="display name for the account")
parser.add_argument(
"--email",
required=True,
help="contact email address for the account (required: users.email is NOT NULL)",
)
parser.add_argument(
"--organization-id",
default=None,
help="optional organization identifier the account belongs to",
)
parser.add_argument(
"--role",
default=str(RoleName.PLATFORM_ADMIN),
choices=[str(role) for role in RoleName],
help="role for the bootstrap account",
)
parser.add_argument(
"--repository-path",
default=None,
help=f"local development repository file (default: {_default_repository_path()})",
)
return parser
def bootstrap_admin(
argv: Sequence[str] | None = None,
*,
stdin: TextIO | None = None,
stdout: TextIO | None = None,
stderr: TextIO | None = None,
prompt: Callable[[str], str] = _prompt_hidden_password,
) -> int:
"""Create the bootstrap administrator. Returns a process exit code."""
parser = build_parser()
args = parser.parse_args(list(argv) if argv is not None else None)
out: TextIO = stdout or sys.stdout
err: TextIO = stderr or sys.stderr
stream: TextIO = stdin or sys.stdin
if _environment_is_production():
print(
"Refusing to run: the bootstrap command is development-only. Production provisioning is blocked until "
"the identity migration, backup, and role gates are approved.",
file=err,
)
return EXIT_REFUSED_PRODUCTION
try:
settings = _bootstrap_settings(args.repository_path)
except OperatorRefusalError as refusal:
print(f"Refusing to run: {refusal.message}", file=err)
return EXIT_REFUSED_PRODUCTION
except UmbraWebError as error:
print(f"Refusing to run: {error.message}", file=err)
return EXIT_ERROR
if settings.is_production:
print(
"Refusing to run: the bootstrap command is development-only. Production provisioning is blocked until "
"the identity migration, backup, and role gates are approved.",
file=err,
)
return EXIT_REFUSED_PRODUCTION
# Checked before the password prompt, not after. Every role except
# platform_admin is organization-scoped, so such a request cannot be
# satisfied. Reporting it afterwards would mean the operator types a
# one-time password and only then learns it was never going to be used.
refusal = _unsatisfiable_assignment(args)
if refusal is not None:
print(f"Refusing to run: {refusal}", file=err)
return EXIT_ERROR
try:
_require_interactive(stream)
except OperatorRefusalError as refusal_error:
print(f"Refusing to run: {refusal_error.message}", file=err)
return EXIT_NOT_INTERACTIVE
password = _read_confirmed_password(prompt, err=err)
if password is None:
return EXIT_PASSWORD_REJECTED
try:
account = _provision(settings, args=args, password=password)
except PasswordPolicyError as error:
requirements = ", ".join(error.unmet_requirements) or "policy"
print(f"Refusing to run: the password does not meet the policy ({requirements}).", file=err)
return EXIT_PASSWORD_REJECTED
except (UmbraWebError, OSError) as error:
print(f"Failed to provision the bootstrap account: {_safe_error_text(error)}", file=err)
return EXIT_ERROR
# Drop the reference as soon as the hash exists. Python cannot wipe an
# immutable string reliably, which is exactly why the prompt is the only
# place the value ever exists and why it is never written anywhere.
password = ""
repository_path = settings.repository_path
mode = ""
if repository_path is not None and repository_path.exists():
mode = oct(stat.S_IMODE(repository_path.stat().st_mode))
print("Bootstrap account created.", file=out)
print(f" username: {account.username}", file=out)
print(f" account id: {account.id}", file=out)
print(f" role: {account.role}", file=out)
print(f" status: {account.status}", file=out)
print(f" must_change_password: {str(account.credential.must_change_password).lower()}", file=out)
if repository_path is not None:
print(f" development store: {repository_path} (mode {mode or 'created on first write'})", file=out)
print(
" The one-time password was not stored. The first login must change it before any other call succeeds.",
file=out,
)
return EXIT_OK
def _environment_is_production(environ: dict[str, str] | None = None) -> bool:
"""Report whether the environment selects production mode.
Checked before any settings are built so that a production environment
always produces the dedicated refusal exit code, even when the remaining
production configuration is incomplete.
"""
env = os.environ if environ is None else environ
return env.get("UMBRA_ENV", "").strip().lower() in {"production", "prod"}
def _bootstrap_settings(repository_path: str | None) -> Settings:
"""Load settings for the bootstrap run, forcing the development repository."""
settings = load_settings()
if settings.is_production:
raise OperatorRefusalError(
"production_refused",
"the bootstrap command must not run with production configuration",
)
path = Path(repository_path).expanduser() if repository_path else _default_repository_path()
return replace(settings, repository_kind="json", repository_path=path)
def _read_confirmed_password(prompt: Callable[[str], str], *, err: TextIO) -> str | None:
"""Prompt twice and require an exact match. Nothing is echoed."""
first = prompt("One-time password (input hidden): ")
second = prompt("Repeat one-time password: ")
if not first or not hmac.compare_digest(first, second):
print("Refusing to run: the two entries did not match.", file=err)
return None
return first
def _unsatisfiable_assignment(args: argparse.Namespace) -> str | None:
"""Return why the requested role and organization cannot both be honoured, if so.
``user_roles_check`` in the control plane makes an organization-scoped
assignment without an organization impossible, and every role other than
``platform_admin`` is organization-scoped. Naming the mismatch here keeps the
reason in front of the operator instead of buried in a domain error.
"""
if args.role == str(RoleName.PLATFORM_ADMIN) or args.organization_id:
return None
return (
f"role {args.role!r} is scoped to an organization, so --organization-id is required. "
"Only a platform administrator can be created without one."
)
def _provision(settings: Settings, *, args: argparse.Namespace, password: str) -> AccountView:
"""Create the account in the local development repository."""
from .dev_repository import JsonDevelopmentRepository
repository = JsonDevelopmentRepository(settings)
service = AccountService(repository, settings)
organization_id = UUID(args.organization_id) if args.organization_id else None
return service.create_account(
enrollment_mode="temporary_password",
username=args.username,
display_name=args.display_name,
role=args.role,
email=args.email,
organization_id=organization_id,
password=password,
operator=OperatorContext(source="operator-cli", interactive=True),
)
def _safe_error_text(error: BaseException) -> str:
"""Render an error without echoing any submitted value."""
if isinstance(error, InputError):
return error.message
return type(error).__name__
LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
def _is_private_lan_address(host: str) -> bool:
"""Whether an address is private and non-loopback.
Checked with the standard library's own parser rather than by string
matching, so an address that merely looks private, such as ``0.0.0.0`` or a
hostname, is rejected. A hostname is rejected outright because resolving it
here would mean a name could point at a public address at bind time and
somewhere else later.
"""
import ipaddress
try:
address = ipaddress.ip_address(host)
except ValueError:
return False
return address.is_private and not address.is_loopback and not address.is_unspecified
def serve(
argv: Sequence[str] | None = None,
*,
stdout: TextIO | None = None,
stderr: TextIO | None = None,
) -> int:
"""Serve the application with uvicorn.
This is both the development command and the deployment command. It serves the
API and the frontend from one origin, so there is no second server to configure
and no CORS to get wrong. The defaults are development ones: loopback, an
in-memory repository, and a refusal to run with production configuration.
Passing the two opt-in flags below makes it a deployment.
"""
parser = argparse.ArgumentParser(
prog="umbratrans-web-serve",
description=(
"Serve the UmbraTrans account-management API and frontend. Defaults are for local "
"development; a deployment behind a TLS terminating reverse proxy needs "
"--allow-production and --allow-lan-bind."
),
)
parser.add_argument("--host", default="127.0.0.1", help="bind address (default: loopback)")
parser.add_argument("--port", type=int, default=8000, help="bind port (default: 8000)")
parser.add_argument(
"--repository",
default="memory",
choices=["memory", "json", "postgres"],
help=(
"development repository. 'postgres' runs against the authoritative store for testing, "
"and requires UMBRATRANS_DATABASE_DSN"
),
)
parser.add_argument(
"--allow-production",
action="store_true",
help=(
"permit running with UMBRA_ENV=production. Required by name; the configuration gate "
"still refuses a deployment without a real database, secure cookies, CSRF, and working mail."
),
)
parser.add_argument(
"--allow-lan-bind",
action="store_true",
help=(
"permit binding a private LAN address so a reverse proxy on another host can reach the "
"application. Refused for a public address, and refused by default."
),
)
args = parser.parse_args(list(argv) if argv is not None else None)
if os.environ.get("UMBRA_ENV", "").strip().lower() in {"production", "prod"} and not args.allow_production:
# The configuration gate is the real control: production cannot be
# configured without a PostgreSQL DSN, Secure cookies, CSRF, an absolute
# verification base URL, and a mail transport, and it refuses a
# file-backed repository outright. This flag is an acknowledgement on top
# of that, so serving real data is always something an operator asked for
# by name rather than something that happened because a variable was set.
print(
"Refusing to serve: this command must be started with --allow-production to run with "
"production configuration. Local development needs no such flag.",
file=stderr or sys.stderr,
)
return EXIT_REFUSED_PRODUCTION
if args.host not in LOOPBACK_HOSTS:
# A reverse proxy reaches the application from another host, so the
# loopback-only default cannot serve a real deployment. It is still the
# default, and this branch is the only way past it, which keeps an
# accidental `--host 0.0.0.0` from quietly exposing the application to
# every interface on the machine.
#
# A specific private address is required rather than a wildcard: naming
# the interface the proxy actually uses is the whole authorisation, and
# `0.0.0.0` would also bind the Docker bridges present on this host.
if not args.allow_lan_bind:
print(
"Refusing to serve: bind to the loopback interface unless --allow-lan-bind is given. "
"The development default is loopback; a deployment behind a reverse proxy on another "
"host must opt in explicitly and name the address it binds.",
file=stderr or sys.stderr,
)
return EXIT_REFUSED_PRODUCTION
if not _is_private_lan_address(args.host):
print(
f"Refusing to serve: {args.host!r} is not a private LAN address. This flag is for a "
"reverse proxy on the local network, not for a public interface. Use a TLS "
"terminating proxy for anything reachable from the internet.",
file=stderr or sys.stderr,
)
return EXIT_REFUSED_PRODUCTION
print(
f"Binding {args.host}:{args.port}, a private LAN address. Traffic to this port is "
"unencrypted and unauthenticated: put a TLS terminating reverse proxy in front of it.",
file=stderr or sys.stderr,
)
import uvicorn
from .app import create_app
settings = load_settings({**os.environ, "UMBRATRANS_REPOSITORY": args.repository})
if args.repository == "json":
settings = settings.with_development_path_defaults()
uvicorn.run(
create_app(settings=settings),
host=args.host,
port=args.port,
log_level="info",
)
return EXIT_OK
def _default_ledger_path() -> Path:
return (DEVELOPMENT_DATA_ROOT / AUDIO_IMPORT_LEDGER_NAME).resolve()
class _JsonAudioImportLedger:
"""Development stand-in for the PostgreSQL audio-import store.
It exists so the file-based import can be exercised end to end, including
idempotency, before the source-import migration is applied and a database
adapter exists. It is deliberately not suitable for anything else: it refuses
production, writes with mode ``0600`` inside a ``0700`` directory, and writes
atomically. It stores a summary of what it committed - the project, the
counts, and the warnings - so a reviewer can read what an import did without
a database and without a nine-megabyte file per book.
"""
def __init__(self, path: Path) -> None:
self.path = path
def _load(self) -> dict[str, Any]:
if not self.path.exists():
return {"schema_version": 1, "imports": [], "plans": {}}
try:
document = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {"schema_version": 1, "imports": [], "plans": {}}
if not isinstance(document, dict):
return {"schema_version": 1, "imports": [], "plans": {}}
document.setdefault("imports", [])
document.setdefault("plans", {})
return document
def last_content_hash(self, external_id: str) -> str | None:
record = self._load()["plans"].get(external_id)
return record.get("content_hash") if isinstance(record, dict) else None
def commit(self, plan, *, imported_at: datetime): # type: ignore[no-untyped-def]
from .audio_import import ImportResult
document = self._load()
result = ImportResult(
project_id=uuid4(),
import_id=uuid4(),
external_id=plan.project.external_id,
content_hash=plan.project.content_hash,
counts=plan.counts(),
imported_at=imported_at,
)
document["imports"].append(
{
"import_id": str(result.import_id),
"project_id": str(result.project_id),
"external_id": result.external_id,
"content_hash": result.content_hash,
"counts": result.counts,
"imported_at": imported_at.isoformat(),
}
)
document["plans"][plan.project.external_id] = {
"content_hash": plan.project.content_hash,
"imported_at": imported_at.isoformat(),
"project": json.loads(json.dumps(asdict(plan.project), default=str)),
"counts": plan.counts(),
"warnings": [asdict(warning) for warning in plan.warnings],
}
self._write(document)
return result
def _write(self, document: dict[str, Any]) -> None:
directory = self.path.parent
directory.mkdir(parents=True, exist_ok=True, mode=LEDGER_DIRECTORY_MODE)
payload = json.dumps(document, indent=2, sort_keys=True, default=str)
handle = tempfile.NamedTemporaryFile(
"w", dir=directory, prefix=".audio-ledger-", delete=False, encoding="utf-8"
)
try:
handle.write(payload)
handle.flush()
os.fsync(handle.fileno())
finally:
handle.close()
os.chmod(handle.name, LEDGER_FILE_MODE)
os.replace(handle.name, self.path)
def import_audio(
argv: Sequence[str] | None = None,
*,
stdout: TextIO | None = None,
stderr: TextIO | None = None,
) -> int:
"""Plan, and optionally commit, a file-based audio-bundle import.
The default is a dry run: it validates the bundle and prints exactly what it
would write. A commit requires ``--confirm`` carrying the bundle's content
hash, because a bare ``--commit`` is too easy to leave in a shell history or a
script. Nothing is written without that confirmation, and production is
refused.
"""
from .audio_import import BundleError, import_bundle, read_bundle, validate_bundle
parser = argparse.ArgumentParser(
prog="umbratrans-import-audio",
description=(
"Import an UmbraAudio metadata bundle. Defaults to a dry run: it validates the bundle and "
"prints the plan. A commit needs --organization-id and --confirm <content_hash>."
),
)
parser.add_argument("--bundle", required=True, help="path to the bundle directory (bundle.json + manifest.jsonl)")
parser.add_argument("--organization-id", default=None, help="organization the project belongs to (UUID)")
parser.add_argument("--project-name", default=None, help="override the imported project name")
parser.add_argument("--project-slug", default=None, help="override the imported project slug")
parser.add_argument("--node-id", default=None, help="override the source node id")
parser.add_argument(
"--commit",
action="store_true",
help="write the plan instead of only reporting it (requires --organization-id and --confirm)",
)
parser.add_argument(
"--confirm",
default=None,
help="the bundle's content hash, echoing what is being imported. Required to commit.",
)
parser.add_argument(
"--ledger",
default=None,
help=f"development ledger file (default: {_default_ledger_path()})",
)
args = parser.parse_args(list(argv) if argv is not None else None)
out: TextIO = stdout or sys.stdout
err: TextIO = stderr or sys.stderr
if _environment_is_production():
print("Refusing to run: the audio import is development-only until the source-import migration is applied.", file=err)
return EXIT_REFUSED_PRODUCTION
try:
bundle = read_bundle(args.bundle)
except BundleError as error:
print(f"Cannot read bundle: {error.message}", file=err)
return EXIT_ERROR
problems = validate_bundle(bundle)
if problems:
print(_render_validation_failure(problems, args.bundle), file=err)
return EXIT_VALIDATION_FAILED
if args.commit and not args.organization_id:
print("Refusing to commit: --organization-id is required.", file=err)
return EXIT_ERROR
organization_id = UUID(args.organization_id) if args.organization_id else uuid4()
ledger = _JsonAudioImportLedger(Path(args.ledger).expanduser() if args.ledger else _default_ledger_path())
try:
outcome = import_bundle(
bundle,
ledger,
organization_id=organization_id,
project_name=args.project_name,
project_slug=args.project_slug,
node_id=args.node_id,
dry_run=not args.commit,
confirm=args.confirm,
)
except BundleError as error:
print(f"Refusing to run: {error.message}", file=err)
return EXIT_ERROR
print(outcome.report, file=out)
if outcome.status == "imported" and outcome.result is not None:
print(f"\nimported: import_id={outcome.result.import_id} project_id={outcome.result.project_id}", file=out)
elif outcome.status == "dry_run":
print("\ndry run: nothing written. Re-run with --commit --confirm <content_hash> to import.", file=out)
return EXIT_OK
def _render_validation_failure(problems: list[str], bundle_path: str) -> str:
lines = [f"VALIDATION FAILED for {bundle_path}: {len(problems)} problem(s)"]
lines.extend(f" - {problem}" for problem in problems)
return "\n".join(lines)
def main(argv: Sequence[str] | None = None) -> int:
"""Console-script entry point for ``umbratrans-bootstrap-admin``."""
return bootstrap_admin(argv)
__all__ = [
"BOOTSTRAP_DISPLAY_NAME",
"BOOTSTRAP_USERNAME",
"EXIT_NOT_INTERACTIVE",
"EXIT_OK",
"EXIT_PASSWORD_REJECTED",
"EXIT_REFUSED_PRODUCTION",
"EXIT_VALIDATION_FAILED",
"bootstrap_admin",
"build_parser",
"import_audio",
"main",
"serve",
]