Implements AccountRepository against the control-plane schema plus the phase-1 identity migration, and wires it in so production can select it. The adapter is not a thin SQL wrapper, because the control plane does not store the two things the application needs on the users row. Role and organization come from user_roles. There is no role column and no organization column on users; a role is assigned through user_roles with a scope, and app.validate_user_role_scope() enforces that the role's scope matches the assignment. Resolving one role per user is therefore a join, and a user holding more than one active assignment is refused rather than resolved to an arbitrary row. The organization on that assignment becomes the scope of every administrative decision, so guessing would hand out the wrong tenant's authority. Reconciling this with project-scoped membership stays an open review question, recorded as such. Email became a required field on account creation. users.email is NOT NULL UNIQUE, so accepting a missing address would let account creation succeed against the development repositories and then fail on the authoritative store. It is now required in the service, the API schema, and the bootstrap command, so it fails identically everywhere. Audit identifiers now belong to the store. audit_events.id is a bigint identity, so the adapter reports that value; the application correlation UUID is preserved in the detail and never displaces the row key. AuditEvent.id is a string for that reason, which removes a type the two stores disagreed about. Three further fail-closed behaviours: a users.status of invited or locked is refused rather than coerced onto active; a row with mfa_required is refused, because this phase cannot satisfy a second factor and authenticating it anyway would grant access to someone who was supposed to present one; and a missing organization blocks access rather than granting it. save_user deliberately does not write username or email, since both are identity keys with no reviewed change path and no audit event behind them. Every operation runs in one transaction on one pooled connection and publishes the caller with SET LOCAL app.current_user_id, so a returned connection cannot carry one request's identity into the next. That is inert until the reviewed RLS policies exist; the explicit WHERE clauses enforce scope today. A statement timeout bounds how long a pooled connection can be held. Production fails closed at two independent gates: the settings layer refuses without a DSN, and the repository boundary refuses to build a PostgreSQL store without one. Verified by running both, including that an unreachable host refuses to start rather than degrading. The migration gains an index on audit_events (target_type, target_id), which the administrator audit filter needs and nothing covered, and the review questions now include the three things that must be checked before provisioning: the roles rows and their scopes, any row with mfa_required set, and any user holding more than one active assignment. Verification, and its limit. 236 tests pass with 3 skipped. The adapter's row mapping, role resolution, conflict mapping, and configuration are unit tested and run everywhere. Its SQL, transactions, and pooled reuse are NOT executed: this environment has no database credentials, because sudo to the postgres user needs a password, there is no umbra database role, and there is no .pgpass. The integration suite is written and selected by UMBRATRANS_TEST_DSN, and its fixture refuses any DSN whose database name contains umbracontrol. A green run here says nothing about whether the SQL is correct.
341 lines
12 KiB
Python
341 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",
|
|
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
|
|
|
|
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",
|
|
]
|