Implements the account and user-management system behind the new web UI against development repositories only. Nothing here connects to, reads, or modifies the production umbracontrol database, and no account is created in any approved environment. Account service and API: - Local temporary-password and OIDC enrollment, separated by type. - Argon2id credentials, must-change enforcement, server-side sessions with hashed tokens, idle and absolute deadlines, rotation and revocation. - Administrator account management: list, get, create, suspend, reactivate, deactivate, revoke sessions, org-scoped audit view. - Hidden-prompt operator command for the first administrator, refusing production mode and refusing to run without a TTY. Temporary passwords are never accepted from a client. The request schema has no password field and refuses unknown fields; the administrator path generates the one-time password in process, hashes it immediately, and returns it once in the creation response. The account can reach no route, including /api/auth/me, until it replaces that password, after which the one-time value no longer authenticates. Security posture: fail-closed configuration, Secure cookies in production, double-submit CSRF, constant-time token comparison, generic authentication errors, audit detail allow-listing, and logger-tree secret redaction. Reviewed but unapplied: migrations/0001_phase1_identity.sql adds users.username, a separate user_credentials table, and additive session columns, with a rollback and open review questions. The PostgreSQL adapter is deliberately absent, so the application refuses to start in production rather than run against an unreviewed store. Verification: 169 tests pass (148 new, 21 pre-existing); ruff check, ruff format, compileall, and make web-check are clean.
337 lines
12 KiB
Python
337 lines
12 KiB
Python
"""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 os
|
|
import stat
|
|
import sys
|
|
from collections.abc import Callable, Sequence
|
|
from dataclasses import replace
|
|
from pathlib import Path
|
|
from typing import Protocol, TextIO
|
|
from uuid import UUID
|
|
|
|
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_REFUSED_PRODUCTION = 3
|
|
EXIT_NOT_INTERACTIVE = 4
|
|
EXIT_PASSWORD_REJECTED = 5
|
|
|
|
|
|
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", default=None, help="optional email address")
|
|
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
|
|
|
|
try:
|
|
_require_interactive(stream)
|
|
except OperatorRefusalError as refusal:
|
|
print(f"Refusing to run: {refusal.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 _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__
|
|
|
|
|
|
def serve(
|
|
argv: Sequence[str] | None = None,
|
|
*,
|
|
stdout: TextIO | None = None,
|
|
stderr: TextIO | None = None,
|
|
) -> int:
|
|
"""Run the development application with uvicorn on the loopback interface."""
|
|
|
|
parser = argparse.ArgumentParser(
|
|
prog="umbratrans-web-serve",
|
|
description="Run the UmbraTrans account-management API for local development (loopback only by default).",
|
|
)
|
|
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"], help="development repository")
|
|
args = parser.parse_args(list(argv) if argv is not None else None)
|
|
|
|
if os.environ.get("UMBRA_ENV", "").strip().lower() in {"production", "prod"}:
|
|
print(
|
|
"Refusing to serve: this development server must not be started with production configuration.",
|
|
file=stderr or sys.stderr,
|
|
)
|
|
return EXIT_REFUSED_PRODUCTION
|
|
if args.host not in {"127.0.0.1", "::1", "localhost"}:
|
|
print(
|
|
"Refusing to serve: bind to the loopback interface for development; production uses the approved proxy.",
|
|
file=stderr or sys.stderr,
|
|
)
|
|
return EXIT_REFUSED_PRODUCTION
|
|
|
|
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 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",
|
|
"bootstrap_admin",
|
|
"build_parser",
|
|
"main",
|
|
"serve",
|
|
]
|