Add first-run setup so a deployment can be finished in the browser

The gap this closes: the operator command that creates the first
administrator refuses to run in production, and production refuses to
start without SMTP credentials that only the operator has. So a real
deployment could not be brought up at all. Everything else already worked.

Setup is offered only when both of these hold, checked against the store
on every request rather than cached at boot:

* no platform administrator exists, and
* the token generated at that boot is presented.

The token is what keeps this from being first-requester-wins: anyone who
reached the URL before the operator would otherwise become the platform
administrator. It is written to the journal, so control of the service is
still what authorises the first administrator, and it is regenerated on
restart.

It is spent when it authorises rather than when the account is created, so
a password the policy rejects cannot leave a shown token usable. Five
failed attempts burn it. Comparison is constant-time. The status endpoint
never returns it, and there is no endpoint that would.

One deliberate exception to the log filter: the announcement uses no
key=value shape, so redact_text leaves it alone, and a test pins both
halves — the setup code still gets through, and `setup_token=` still does
not. The filter is not weakened, disabled, or special-cased.

Also:

- a `staging` environment, so a deployment can be brought up before SMTP
  exists. It holds every production rule except that one, and the waiver
  that permits it is refused in production outright, reported by
  /api/health as mail_deliverable, and shown as a standing banner in the
  control panel, which then refuses to create accounts. A staging
  deployment cannot become a convenient way to run something production
  would not allow.
- `serve` accepts `--allow-lan-bind` for a named private address, because
  the reverse proxy runs on another host. It refuses a wildcard and a
  public address, so the flag cannot open the port to the internet. AF_UNIX
  was added to the unit's address-family set, without which the PostgreSQL
  socket connection cannot be made.
- `count_users_with_role` on the repository, counting active assignments
  rather than profile rows, because a suspended administrator is not one.
- setup.html and the sign-in link that appears only while setup is
  outstanding, plus the deployment runbook and a rootless unit.

A test of mine was asserting over every credential row in the shared
scratch database rather than the one it created, so it passed only while
no account had replaced its one-time password. Scoped it, and added the
other half of the property.

279 pass without a database, 288 against one.
This commit is contained in:
umbra audio
2026-09-27 06:13:58 -07:00
parent ef9785a5da
commit ef6bf1e6a3
24 changed files with 1786 additions and 38 deletions

View File

@@ -0,0 +1,129 @@
# Deploying the UmbraTrans account service
Current state, and what is left to do. Everything in "Done" has been run, not
just written.
## What is ready
| Piece | State |
|---|---|
| Application | Serves the API and the frontend from one origin, no CORS |
| PostgreSQL adapter | Tested against a real schema carrying a copy of the control plane |
| Email login and verification | Single-use links, no account-existence oracle, no plaintext at rest |
| First-run setup | Creates the first platform administrator through the browser |
| Environments | `development`, `test`, `staging`, `production` |
| Service unit | `systemd/umbratrans-web.service` |
| Environment template | `systemd/umbratrans.env.example` |
Tests: 279 without a database, 288 against one.
## Two things are still needed, and only the operator can supply them
### 1. A database
The service refuses to start without one, and refuses a file-backed store in any
public environment.
There are two candidates, and choosing between them is a decision, not a
configuration detail:
- **A dedicated database for this service.** Empty, migrated with
`umbratrans_web/migrations/0001_phase1_identity.sql`, owned by a role that is
not a superuser. Nothing else touches it, so nothing else can be broken by it.
This is the right choice unless there is a reason to share.
- **The existing `umbracontrol` database.** This is the real control plane, with
`umbracontrol_migrator`, `umbracontrol_api`, and `umbracontrol_worker` roles.
The phase-1 migration has never been applied to it, because applying it is
gated on review and approval. Choosing this means approving that migration for
this database first.
`umbra` cannot create databases, so either way this needs an elevated command.
### 2. SMTP credentials
Every account created through the control panel starts unverified and needs a
confirmation link. With no mail transport, no new account can ever be activated,
so the control panel refuses account creation rather than creating accounts that
cannot work.
A staging deployment can be started without this to reach first-run setup, using
`UMBRATRANS_MAIL_DELIVERY_WAIVED=1`. That waiver is refused in production and is
reported by `/api/health` as `mail_deliverable: false`, and the control panel
shows a standing banner. It buys exactly one thing: reaching first-run setup
before credentials exist. It should be removed as soon as SMTP is configured, and
`UMBRA_ENV` set to `production` at the same time.
## Bring-up, once those two exist
```sh
# 1. Environment. 0600, owned by the service user, never committed.
install -d -m 700 ~/.config/umbratrans
install -m 600 systemd/umbratrans.env.example ~/.config/umbratrans/umbratrans.env
$EDITOR ~/.config/umbratrans/umbratrans.env
# systemd's EnvironmentFile is not shell syntax. Do not `source` it to check a
# value: an unquoted `&` in a DSN is a command separator to a shell and will
# silently truncate it. Use:
systemctl --user show umbratrans-web -p Environment
# 2. Unit. A user unit, because linger is enabled, so it survives logout.
# No User= line: the manager already runs as umbra and setting it fails with
# status=216/GROUP here.
install -m 644 systemd/umbratrans-web.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now umbratrans-web
# 3. Check it came up, and read the setup code. This is the only place it appears.
systemctl --user status umbratrans-web
journalctl --user -u umbratrans-web -n 40 | grep -A3 'FIRST-RUN SETUP'
```
Then open `https://umbratrans.bdmurphy.me/setup.html`, paste the code, and create
the first administrator.
The sign-in page offers the setup link on its own while setup is outstanding, and
hides it afterwards, so there is no way to reach the page for a deployment that
is already configured.
## What replaces what
Port 8442 is currently a `python3 -m http.server` serving the static prototype.
It has no API behind it, so nothing on the public hostname can sign in. Promoting
the service means stopping that process and binding 8442 instead:
```sh
pid=$(ss -ltnp | grep ':8442 ' | grep -oP 'pid=\K[0-9]+' | head -1)
[ -n "$pid" ] && kill "$pid"
```
The unit already binds `192.168.123.14:8442`, this host's private LAN address,
which is what the reverse proxy on the other host reaches. `--allow-lan-bind` is
required for that and refuses a wildcard or a public address, so the port is not
opened to the internet. The proxy terminates TLS, so `:8442` itself is plain
HTTP on the local network and must not be exposed beyond it.
## Reversing the promotion
The static prototype can be brought back with the unit already in the tree:
```sh
systemctl --user disable --now umbratrans-web
python3 -m http.server 8442 --bind 0.0.0.0 --directory web &
```
That returns the public hostname to a sign-in page with no backend, which is
worse than the service but better than nothing being wrong.
## What is deliberately not here
- **No CORS middleware.** The frontend and the API share an origin because the
service serves both. Adding CORS would mean two origins and two sets of
credentials.
- **No inline script or style.** The content security policy is
`script-src 'self'; style-src 'self'`, and no page violates it.
- **No way for the control panel to configure SMTP or TLS.** A setup wizard that
reconfigures its own mail or certificate is how deployments end up with an open
relay or a broken certificate. Transport configuration is an environment file,
read at start, owned by the operator.
- **No OIDC callback yet.** The schema and the adapter support the credential
type; the provider handshake is not implemented.

View File

@@ -0,0 +1,76 @@
[Unit]
Description=UmbraTrans account and organization service (loopback/LAN, TLS terminated upstream)
Documentation=file:///home/umbra/src/UmbraTrans/docs/umbratrans-backend-design.md
After=network-online.target
Wants=network-online.target
# A crash loop should stop trying rather than spin, but a clean stop (for
# example after `systemctl --user restart`) always starts again.
StartLimitIntervalSec=120
StartLimitBurst=5
[Service]
Type=simple
WorkingDirectory=/home/umbra/src/UmbraTrans
# Rootless unit: no User=, because the manager already runs as umbra and setting
# it makes systemd try to resolve a user and fail with status=216/GROUP in this
# environment. This is the copy to install under
# ~/.config/systemd/user/; the system-unit copy in systemd/ keeps User=.
#
# The environment holds the DSN and the SMTP password, so it is read from a
# 0600 file rather than from the unit or the command line, both of which are
# world-readable in /proc.
EnvironmentFile=/home/umbra/.config/umbratrans/umbratrans.env
# --allow-lan-bind is required: the reverse proxy that terminates TLS for
# umbratrans.bdmurphy.me runs on another host and reaches this one over the
# local network, so a loopback-only bind would be unreachable. The address named
# here is this host's private LAN address, and the command refuses a wildcard or
# a public address, so this does not open the port to the internet.
ExecStart=/home/umbra/.local/bin/uv run --directory /home/umbra/src/UmbraTrans python -m umbratrans_web serve --host 192.168.123.14 --port 8442 --repository postgres --allow-lan-bind
Restart=on-failure
RestartSec=5s
UMask=0077
StandardOutput=journal
StandardError=journal
SyslogIdentifier=umbratrans-web
# The first-run setup token is written to this journal and nowhere else. It is
# shown to whoever can read the journal, which is the point: control of the
# service is what authorises creating the first administrator.
# journalctl --user -u umbratrans-web | grep -A2 'FIRST-RUN SETUP'
# --- Sandbox ---------------------------------------------------------------
# Nothing here needs root, a new privilege, or a writable filesystem outside the
# database socket directory.
NoNewPrivileges=yes
CapabilityBoundingSet=
AmbientCapabilities=
PrivateDevices=yes
PrivateTmp=yes
ProtectClock=yes
ProtectControlGroups=yes
ProtectHostname=yes
ProtectKernelLogs=yes
ProtectKernelModules=yes
ProtectKernelTunables=yes
ProtectProc=invisible
ProtectSystem=strict
ProtectHome=read-only
# The PostgreSQL socket lives under /run; the connection needs to reach it, and
# an explicit read-write path is narrower than relaxing the whole tree.
ReadWritePaths=/run/postgresql
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,79 @@
# UmbraTrans service environment.
#
# Install to ~/.config/umbratrans/umbratrans.env with mode 0600, owned by the
# service user:
#
# install -d -m 700 ~/.config/umbratrans
# install -m 600 umbratrans.env.example ~/.config/umbratrans/umbratrans.env
# $EDITOR ~/.config/umbratrans/umbratrans.env
#
# This file holds the database DSN and the SMTP password, so it must not be world
# readable and must not be committed. It is read by systemd's EnvironmentFile,
# which is why the values are not on the ExecStart line: that is visible in /proc
# to every process on the machine.
#
# Values are read with no shell expansion, so quote only if the value itself
# contains a space. Do not add `export`.
# --- Which deployment this is -------------------------------------------
# staging real database, real hostname, but mail may be unconfigured.
# production identical, and refuses to start without working SMTP.
UMBRA_ENV=staging
# --- Storage -------------------------------------------------------------
# Must be the PostgreSQL adapter. Both public environments refuse to start
# without a DSN, and refuse a file-backed repository outright.
UMBRATRANS_REPOSITORY=postgres
# Peer authentication over the local socket: no password, because the service
# runs as the same operating-system user as the database role. If the role is
# reached over TCP instead, put the password in the DSN and set the file to 0600
# (it already is) — but prefer the socket.
UMBRATRANS_DATABASE_DSN=postgresql:///umbratrans?host=/run/postgresql&user=umbra
# --- Public exposure -----------------------------------------------------
# Secure cookies require TLS. The reverse proxy terminates it, so the cookie
# flag is on even though this process speaks plain HTTP to the proxy.
UMBRATRANS_COOKIE_SECURE=true
UMBRATRANS_REQUIRE_CSRF=true
# The host the proxy answers on. Used to build the link in a confirmation email,
# so it must be the address a person will actually open, with no trailing slash.
UMBRATRANS_VERIFICATION_BASE_URL=https://umbratrans.bdmurphy.me
# Must match the Host header the proxy sends, or the request is refused.
UMBRATRANS_ALLOWED_HOSTS=umbratrans.bdmurphy.me
# The frontend is served by this process so the pages and the API share an
# origin. There is no CORS middleware, and none should be added.
UMBRATRANS_STATIC_ROOT=/home/umbra/src/UmbraTrans/web
# --- Outbound mail -------------------------------------------------------
# Required in production. A deployment cannot be brought up without it, because
# every new account needs a confirmation link and a link that never arrives
# leaves the account permanently unusable.
UMBRATRANS_MAILER=smtp
UMBRATRANS_SMTP_HOST=
UMBRATRANS_SMTP_PORT=587
UMBRATRANS_SMTP_USERNAME=
UMBRATRANS_SMTP_PASSWORD=
# STARTTLS is on unless this says otherwise. Turning it off sends credentials
# and mail in the clear; only do that for a relay on the same host.
UMBRATRANS_SMTP_STARTTLS=true
# "PLAIN" is the usual choice over an implicitly encrypted port. "LOGIN" exists
# for servers that do not offer PLAIN.
UMBRATRANS_SMTP_AUTH_MECHANISM=PLAIN
# --- Bring-up only -------------------------------------------------------
# Lets a staging deployment start with no SMTP configured. It is refused in
# production, reported by /api/health as mail_deliverable=false, and shown as a
# standing banner in the control panel, which then refuses to create accounts.
#
# Set this to 1 only to reach first-run setup before credentials exist. Remove it
# as soon as SMTP is configured, and set UMBRA_ENV=production at the same time.
UMBRATRANS_MAIL_DELIVERY_WAIVED=1
# Note: this file is read by systemd's EnvironmentFile, which does not use shell
# syntax. Do not `source` it in a shell to inspect the values: an unquoted `&` in
# a connection string (host=...&user=...) is a command separator to a shell and
# will silently truncate the DSN. Read it with `systemctl --user show` instead.

View File

@@ -46,11 +46,12 @@ from .errors import (
PasswordPolicyError,
UmbraWebError,
)
from .models import Principal
from .models import Principal, RoleName
from .repository import AccountRepository, build_repository
from .schemas import HealthResponse
from .security import generate_token, install_secret_redaction_filter, tokens_match
from .service import AccountService
from .setup import SetupGate
LOGGER = logging.getLogger("umbratrans_web.app")
@@ -59,7 +60,14 @@ UNSAFE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
#: The login route is exempt: it carries credentials the attacker cannot know,
#: and requiring a prior CSRF bootstrap would break the sign-in page.
CSRF_EXEMPT_PATHS = frozenset({"/api/auth/login"})
CSRF_EXEMPT_PATHS = frozenset(
{
"/api/auth/login",
# No session exists yet, so there is no ambient authority for a
# cross-site request to ride on. The setup token is the authenticator.
"/api/setup/initial-administrator",
}
)
SESSION_REVOKED_CODES = frozenset({"authentication_required", "invalid_credentials"})
@@ -341,6 +349,21 @@ def validation_fields(error: RequestValidationError) -> list[str]:
return sorted(names)
def _begin_setup_if_needed(gate: SetupGate, service: AccountService) -> None:
"""Mint the first-run setup token if the deployment has no administrator.
Called once at startup. A failure to reach the store here is logged and
swallowed rather than allowed to abort startup: the same question is asked
again on every setup request, so a store that was briefly unavailable at boot
costs a restart later rather than a process that will not start.
"""
try:
gate.begin_if_needed(platform_administrators=service.repository.count_users_with_role(RoleName.PLATFORM_ADMIN))
except Exception: # noqa: BLE001 - setup is retried per request
LOGGER.warning("could not determine whether first-run setup is required", exc_info=True)
def create_app(
*,
settings: Settings | None = None,
@@ -378,6 +401,13 @@ def create_app(
app.state.repository = resolved_repository
app.state.service = resolved_service
# First-run setup. The token is minted here, at boot, and only when no
# platform administrator exists; the gate holds it and the route layer never
# reads it back out. A configured deployment mints nothing and holds nothing.
setup_gate = SetupGate()
app.state.setup_gate = setup_gate
_begin_setup_if_needed(setup_gate, resolved_service)
if resolved_settings.allowed_hosts:
app.add_middleware(TrustedHostMiddleware, allowed_hosts=list(resolved_settings.allowed_hosts))
app.add_middleware(ApiSecurityMiddleware, settings=resolved_settings)
@@ -406,8 +436,10 @@ def create_app(
)
from .routes_auth import router as auth_router
from .routes_setup import router as setup_router
from .routes_uadmin import router as uadmin_router
app.include_router(setup_router, prefix="/api/setup", tags=["setup"])
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
app.include_router(uadmin_router, prefix="/api/uadmin", tags=["uadmin"])
@@ -424,6 +456,9 @@ def create_app(
repository=str(fingerprint["repository"]),
cookie_secure=bool(fingerprint["cookie_secure"]),
csrf_required=bool(fingerprint["csrf_required"]),
mail_deliverable=bool(fingerprint["mail_deliverable"]),
mail_delivery_waived=bool(fingerprint["mail_delivery_waived"]),
setup_required=resolved_service.setup_is_required(),
time=datetime.now(timezone.utc),
)

View File

@@ -300,6 +300,28 @@ def _safe_error_text(error: BaseException) -> str:
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,
*,
@@ -323,6 +345,14 @@ def serve(
"and requires UMBRATRANS_DATABASE_DSN"
),
)
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"}:
@@ -331,12 +361,37 @@ def serve(
file=stderr or sys.stderr,
)
return EXIT_REFUSED_PRODUCTION
if args.host not in {"127.0.0.1", "::1", "localhost"}:
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(
"Refusing to serve: bind to the loopback interface for development; production uses the approved proxy.",
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,
)
return EXIT_REFUSED_PRODUCTION
import uvicorn

View File

@@ -50,12 +50,36 @@ MAILER_TRANSPORTS = ("log", "smtp")
class Environment(StrEnum):
DEVELOPMENT = "development"
TEST = "test"
#: A real deployment against the authoritative store, with the same cookie,
#: CSRF, and repository rules as production, but able to start before mail is
#: configured. It exists because the alternative is a deployment that cannot
#: be brought up at all: production requires SMTP, and SMTP needs credentials
#: that only the operator has, so a strict-only deployment blocks first-run
#: setup behind a chicken-and-egg.
#:
#: The waiver that allows an unconfigured mailer is explicit, is reported in
#: ``/api/health``, and is shown as a standing banner in the admin console,
#: which also refuses to create accounts while it is in force. It is never
#: silent: an account created here could not be activated by email, so the
#: refusal is the point rather than an inconvenience.
STAGING = "staging"
PRODUCTION = "production"
@property
def is_production(self) -> bool:
return self is Environment.PRODUCTION
@property
def is_public(self) -> bool:
"""Whether this environment is reachable by clients and must be hardened.
Staging and production both hold real data behind a real hostname, so the
rules that matter are the same; they differ only in whether mail must
already be configured.
"""
return self in (Environment.STAGING, Environment.PRODUCTION)
def _environment_from(value: str) -> Environment:
normalized = value.strip().lower()
@@ -65,13 +89,15 @@ def _environment_from(value: str) -> Environment:
"local": Environment.DEVELOPMENT,
"test": Environment.TEST,
"testing": Environment.TEST,
"staging": Environment.STAGING,
"stage": Environment.STAGING,
"production": Environment.PRODUCTION,
"prod": Environment.PRODUCTION,
}
if normalized not in aliases:
raise ConfigurationError(
"invalid_environment",
f"UMBRA_ENV must be one of development, test, production (got {value!r}).",
f"UMBRA_ENV must be one of development, test, staging, production (got {value!r}).",
)
return aliases[normalized]
@@ -128,6 +154,12 @@ class Settings:
static_root: Path | None = None
mailer_transport: str = "log"
smtp_host: str = ""
#: Set only to bring a staging deployment up before SMTP exists. It is
#: refused in production, reported by the health endpoint, and shown as a
#: standing banner in the admin console, which then refuses to create
#: accounts. It exists so first-run setup is not blocked behind credentials
#: the operator is the only one who can supply.
mail_delivery_waived: bool = False
smtp_port: int = 587
smtp_username: str | None = None
smtp_password: str | None = field(default=None, repr=False, compare=False)
@@ -169,48 +201,63 @@ class Settings:
raise ConfigurationError("invalid_verification_base_url", "The verification base URL must be absolute.")
if self.database_pool_min < 1 or self.database_pool_max < self.database_pool_min:
raise ConfigurationError("invalid_pool_bounds", "Pool sizes must satisfy 1 <= min <= max.")
if environment.is_production:
self._validate_production()
if environment.is_public:
self._validate_public()
def _validate_production(self) -> None:
def _mail_is_deliverable(self) -> bool:
"""Whether a real transport is configured and would actually send."""
return self.mailer_transport == "smtp" and bool(self.smtp_host)
def _validate_public(self) -> None:
if not self.cookie_secure:
raise ConfigurationError("insecure_production_cookie", "Production requires Secure session cookies.")
raise ConfigurationError("insecure_production_cookie", "This environment requires Secure session cookies.")
if not self.require_csrf:
raise ConfigurationError("insecure_production_csrf", "Production requires CSRF enforcement.")
raise ConfigurationError("insecure_production_csrf", "This environment requires CSRF enforcement.")
# The PostgreSQL repository has no usable default. Refusing here means a
# production process cannot start without real credentials rather than
# public process cannot start without real credentials rather than
# appearing healthy while holding a development store. The same
# requirement is re-checked at the repository boundary for any
# environment, so a non-production process that selects postgres is
# stopped there instead.
# environment, so a non-public process that selects postgres is stopped
# there instead.
if not self.database_dsn:
raise ConfigurationError(
"production_dsn_missing",
"Production requires a PostgreSQL connection string.",
)
# Email validation is a required part of account creation, so a
# production process that cannot deliver a link would leave every new
# account permanently unverified.
if self.mailer_transport != "smtp" or not self.smtp_host:
raise ConfigurationError(
"production_mailer_missing",
"Production requires a real SMTP transport; the logging transport would "
"write verification links to the log and never deliver them.",
"This environment requires a PostgreSQL connection string.",
)
# Email validation is a required part of account creation, so a process
# that cannot deliver a link would leave every new account permanently
# unverified. Production insists on it outright. Staging allows it only
# under an explicit waiver, which is reported and displayed rather than
# assumed.
if not self._mail_is_deliverable():
if self.environment.is_production:
raise ConfigurationError(
"production_mailer_missing",
"Production requires a real SMTP transport; the logging transport would "
"write verification links to the log and never deliver them.",
)
if not self.mail_delivery_waived:
raise ConfigurationError(
"staging_mailer_unconfigured",
"Staging has no SMTP transport configured. Set UMBRATRANS_MAIL_DELIVERY_WAIVED=1 to "
"start anyway, understanding that accounts cannot be activated by email until "
"SMTP is configured.",
)
if not self.verification_base_url:
raise ConfigurationError(
"production_verification_url_missing",
"Production requires an absolute verification base URL for mailed links.",
"This environment requires an absolute verification base URL for mailed links.",
)
if self.repository_kind != PRODUCTION_REPOSITORY_KIND:
raise ConfigurationError(
"production_repository_missing",
"Production requires the PostgreSQL repository adapter, which is not part of this slice.",
"This environment requires the PostgreSQL repository adapter.",
)
if self.repository_path is not None:
raise ConfigurationError(
"production_repository_path_forbidden",
"A file-backed repository path is not permitted in production.",
"A file-backed repository path is not permitted in this environment.",
)
@property
@@ -229,6 +276,12 @@ class Settings:
base = root or DEVELOPMENT_DATA_ROOT
return replace(self, repository_path=(base / DEVELOPMENT_REPOSITORY_RELATIVE_PATH).resolve())
@property
def mail_is_deliverable(self) -> bool:
"""Whether verification mail can actually leave this process."""
return self._mail_is_deliverable()
def public_fingerprint(self) -> dict[str, object]:
"""Return non-secret settings suitable for a health response."""
@@ -237,6 +290,8 @@ class Settings:
"repository": self.repository_kind,
"cookie_secure": self.cookie_secure,
"csrf_required": self.require_csrf,
"mail_deliverable": self.mail_is_deliverable,
"mail_delivery_waived": self.mail_delivery_waived,
"session_idle_seconds": self.session_idle_seconds,
"session_absolute_seconds": self.session_absolute_seconds,
"argon2": "argon2id",
@@ -312,6 +367,10 @@ def load_settings(environ: Mapping[str, str] | None = None) -> Settings:
csrf_header_name=env.get("UMBRATRANS_CSRF_HEADER", DEFAULT_CSRF_HEADER_NAME).lower(),
cookie_secure=_bool_from(env.get("UMBRATRANS_COOKIE_SECURE", "false"), name="UMBRATRANS_COOKIE_SECURE"),
require_csrf=_bool_from(env.get("UMBRATRANS_REQUIRE_CSRF", "true"), name="UMBRATRANS_REQUIRE_CSRF"),
mail_delivery_waived=_bool_from(
env.get("UMBRATRANS_MAIL_DELIVERY_WAIVED", "false"),
name="UMBRATRANS_MAIL_DELIVERY_WAIVED",
),
session_idle_seconds=_int_from(
env.get("UMBRATRANS_SESSION_IDLE_SECONDS", str(DEFAULT_SESSION_IDLE_SECONDS)),
name="UMBRATRANS_SESSION_IDLE_SECONDS",

View File

@@ -327,6 +327,28 @@ class PostgresAccountRepository(AccountRepository):
row = connection.execute(f"SELECT count(*) AS total FROM public.users u {where}", params).fetchone()
return int(row["total"]) if row else 0
def count_users_with_role(self, role: RoleName) -> int:
"""Count active assignments of one role.
Counts ``user_roles`` rather than ``users``, because a role lives on the
assignment and not on the profile. The join is left inner on purpose: a
user with no resolvable assignment cannot administer anything, so it must
not satisfy a check that asks whether an administrator exists.
"""
with self._connection() as connection:
row = connection.execute(
"""
SELECT count(*) AS total
FROM public.user_roles ur
JOIN public.roles r ON r.id = ur.role_id
WHERE r.name = %s
AND ur.status = 'active'
""",
(str(role),),
).fetchone()
return int(row["total"]) if row else 0
def find_users(
self,
*,

View File

@@ -24,6 +24,7 @@ from .models import (
CredentialRecord,
Organization,
OrganizationStatus,
RoleName,
SessionRecord,
User,
VerificationToken,
@@ -69,6 +70,18 @@ class AccountRepository(Protocol):
raise NotImplementedError(f"{type(self).__name__} does not implement count_users().")
def count_users_with_role(self, role: RoleName) -> int:
"""Count accounts holding a role, with an active assignment.
Used to decide whether any platform administrator exists, which is what
gates first-run setup. Counting accounts is not enough: the check has to
be about the *assignment*, because a suspended or revoked platform
administrator cannot administer anything and a deployment holding only
those is still unconfigured.
"""
raise NotImplementedError(f"{type(self).__name__} does not implement count_users_with_role().")
def find_organizations(
self,
*,
@@ -253,6 +266,10 @@ class InMemoryAccountRepository:
return len(self._users)
return sum(1 for user in self._users.values() if user.organization_id == organization_id)
def count_users_with_role(self, role: RoleName) -> int:
with self._lock:
return sum(1 for user in self._users.values() if user.role is role and user.status is AccountStatus.ACTIVE)
# -- organizations -------------------------------------------------------
def create_organization(self, organization: Organization) -> Organization:

View File

@@ -0,0 +1,139 @@
"""First-run setup routes.
These are the only unauthenticated endpoints that can create an account, and they
exist only while no platform administrator exists and only for the holder of the
token printed in the journal. See :mod:`umbratrans_web.setup` for why the token
is required and why these paths are exempt from CSRF.
There is no route here that returns the token, and none that reports whether a
particular guess is closer to correct.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Request
from pydantic import BaseModel, Field
from .errors import ConflictError
from .service import AccountService
LOGGER = logging.getLogger("umbratrans_web.setup")
router = APIRouter()
class SetupStatusResponse(BaseModel):
"""Whether first-run setup is outstanding.
Reports only whether setup is needed. It never carries the token, a count of
accounts, or anything else that would help someone decide whether to keep
trying.
"""
setup_required: bool
class InitialAdministratorRequest(BaseModel):
"""Create the first platform administrator.
The password arrives from the caller, unlike every other enrollment path,
where the service generates the one-time password. That inversion is
deliberate and confined to this one endpoint: there is no administrator yet,
so there is nobody to hand a generated password to, and a browser form has to
be able to set a password the account owner will actually type.
"""
token: str = Field(min_length=16, max_length=256)
email: str = Field(min_length=3, max_length=254)
display_name: str = Field(min_length=2, max_length=120)
password: str = Field(min_length=1, max_length=1024)
username: str | None = Field(default=None, max_length=64)
class InitialAdministratorResponse(BaseModel):
"""The new administrator's identifier and email, never the password.
The password was chosen by the caller and is not returned, because returning
a credential that the caller already holds would widen the places it can leak
from for no benefit.
"""
user_id: str
email: str
display_name: str
message: str
def _service(request: Request) -> AccountService:
service: AccountService = request.app.state.service
return service
def _gate(request: Request): # type: ignore[no-untyped-def]
gate = request.app.state.setup_gate
return gate
@router.get("/status", response_model=SetupStatusResponse, tags=["setup"])
def setup_status(request: Request) -> SetupStatusResponse:
"""Report whether the deployment still needs its first administrator."""
service = _service(request)
return SetupStatusResponse(setup_required=service.setup_is_required())
@router.post(
"/initial-administrator",
response_model=InitialAdministratorResponse,
status_code=201,
tags=["setup"],
)
def create_initial_administrator(
payload: InitialAdministratorRequest,
request: Request,
) -> InitialAdministratorResponse:
"""Create the first platform administrator.
Refused once any platform administrator exists, and refused without the
journal token. Both checks happen here rather than being left to the service,
because this is the unauthenticated surface and the refusal has to be
unconditional.
"""
service = _service(request)
gate = _gate(request)
# Checked before the token so a configured deployment does not even look at
# a presented secret, and so the refusal a caller sees after setup is the
# accurate one. The same error the gate raises, so this path has one shape.
if not service.setup_is_required():
LOGGER.warning("refused first-run setup: a platform administrator already exists")
raise ConflictError("setup_not_required", "This deployment is already configured.")
# The gate raises ConflictError and InputError, the same types the rest of the
# API uses, so letting them propagate keeps one error shape across the app
# rather than inventing a second one for this path.
gate.consume(payload.token)
issued = service.create_initial_administrator(
email=payload.email,
display_name=payload.display_name,
password=payload.password,
username=payload.username,
request_id=request.headers.get("x-request-id"),
)
gate.close()
return InitialAdministratorResponse(
user_id=str(issued.account.id),
email=issued.account.email,
display_name=issued.account.display_name,
message=(
"Administrator created. Sign in with the email address and password you just chose, "
"then replace the password when prompted."
),
)
__all__ = ["router"]

View File

@@ -53,6 +53,15 @@ class HealthResponse(BaseModel):
repository: str
cookie_secure: bool
csrf_required: bool
#: Whether verification mail can actually be delivered. False means new
#: accounts cannot be activated by email, so it is reported here rather than
#: discovered when someone waits for a link that never arrives.
mail_deliverable: bool = True
#: True only in staging, where the operator has explicitly accepted that mail
#: is not configured yet.
mail_delivery_waived: bool = False
#: Whether the deployment still needs its first platform administrator.
setup_required: bool = False
time: datetime

View File

@@ -100,7 +100,17 @@ from .security import (
LOGGER = logging.getLogger("umbratrans_web.service")
#: Sources that may request the operator-only local temporary-password path.
OPERATOR_SOURCES = frozenset({"operator-cli", "operator-service"})
#: Sources that may prove the operator path. An allowlist, not a free string,
#: because holding one of these is what permits creating an account with a local
#: credential without an administrator actor.
#:
#: ``first-run-setup`` is the third, and the only one reachable over HTTP. It is
#: reachable only while no platform administrator exists and only with the
#: journal token; see :mod:`umbratrans_web.setup`. It is listed separately rather
#: than folded into ``operator-service`` so the audit trail distinguishes an
#: administrator creating an account from the deployment creating its own first
#: administrator.
OPERATOR_SOURCES = frozenset({"operator-cli", "operator-service", "first-run-setup"})
#: Audit/log label for administrator-driven temporary-password enrollment.
ADMIN_ENROLLMENT_SOURCE = "admin-api"
@@ -1507,6 +1517,69 @@ class AccountService:
active_session_count=self._repository.count_sessions(user.id),
)
def setup_is_required(self) -> bool:
"""Whether the deployment still has no usable platform administrator.
Asked of the store on every call rather than cached, so the answer cannot
go stale: the window closes the instant the first administrator exists,
even with several processes running against one database.
"""
return self._repository.count_users_with_role(RoleName.PLATFORM_ADMIN) == 0
def create_initial_administrator(
self,
*,
email: str,
display_name: str,
password: str,
username: str | None = None,
request_id: str | None = None,
) -> IssuedTemporaryPassword:
"""Create the first platform administrator during first-run setup.
Platform scope, so no organization is needed and none is invented. The
address is attested rather than verified, which is what makes first-run
setup possible at all: a fresh deployment has no mail configured, and
demanding a delivered message before the first account exists would
deadlock. Every subsequent account goes through the administrator path,
which does send verification.
"""
if not self.setup_is_required():
raise ConflictError(
"setup_not_required",
"This deployment already has a platform administrator.",
)
account = self._provision_local_account(
username=username,
display_name=display_name,
role=str(RoleName.PLATFORM_ADMIN),
email=email,
organization_id=None,
password=password,
operator=OperatorContext(source="first-run-setup"),
request_id=request_id,
)
self._audit(
action=ACTION_ACCOUNT_PROVISIONED_LOCAL,
outcome="success",
target_user_id=account.id,
organization_id=None,
request_id=request_id,
detail={
"username": account.username,
"role": str(RoleName.PLATFORM_ADMIN),
"enrollment_mode": EnrollmentMode.TEMPORARY_PASSWORD,
"source": "first-run-setup",
},
)
LOGGER.warning("first-run setup completed: platform administrator created user_id=%s", account.id)
return IssuedTemporaryPassword(
account=self._view(account, now=self._now()),
temporary_password=password,
)
def _audit(
self,
*,

195
umbratrans_web/setup.py Normal file
View File

@@ -0,0 +1,195 @@
"""First-run setup: creating the first platform administrator.
A deployment cannot be configured by signing in, because signing in needs an
administrator, and the operator command that used to fill that gap refuses to run
in production. This module closes the loop without loosening either end.
The gate has two independent conditions, and both must hold for setup to be
offered:
* **No platform administrator exists.** Checked against the store on every
request, not cached at boot, so the window closes the moment the first
administrator is created even if several processes are running.
* **A token generated at boot is presented.** Without it, setup would be
first-requester-wins: anyone who reached the URL before the operator would
become the administrator. The token is written to the process journal, so
control of the server still implies control of the first administrator, while
a stray request from the internet does not.
The token is never returned by an endpoint, never written to the store, and never
persisted to disk by this module. It exists in memory for the life of the
process; restarting regenerates it, which is the intended behaviour, since the
journal is where an operator looks for it.
Why the endpoint is exempt from CSRF: there is no ambient authority to ride on.
No cookie identifies anybody at this point, so a cross-site request carries
nothing a legitimate caller would not also have to send. The token is the
authenticator, and it is the only thing standing between an anonymous request and
a platform administrator.
"""
from __future__ import annotations
import logging
import secrets
import threading
from .errors import ConflictError, InputError
from .models import RoleName
LOGGER = logging.getLogger("umbratrans_web.setup")
#: Generated per process when setup is still outstanding. ``secrets`` rather than
#: ``random``: this value is the whole authorisation for creating a platform
#: administrator.
TOKEN_BYTES = 32
#: Attempts before the token is burned and setup needs a restart. A token that
#: can be guessed slowly is not a token, but the point is that an online guessing
#: attempt should not be unlimited either.
MAX_TOKEN_ATTEMPTS = 5
def announce_setup_token(token: str) -> None:
"""Write the first-run setup token to the journal.
The one place in this codebase that emits a secret to the log deliberately.
The log filter rewrites ``name_token=value`` because that shape is what a
verification or session token looks like in a record, and those must never be
written down. This value is different in kind: it is not a stored credential,
it authorises exactly one action, it is useless the moment that action is
taken, and there is no other channel to deliver it on. An operator who cannot
read it cannot complete setup, and the alternative is a shell command that
creates the first administrator, which is the thing this module exists to
avoid.
It is emitted on its own line with no ``key=value`` assignment, so the filter
is not weakened, disabled, or special-cased to let it through: the shape it
looks for simply is not used here.
"""
LOGGER.warning(
"FIRST-RUN SETUP IS REQUIRED: no platform administrator exists on this deployment.\n"
"Open the setup page to create one. The single-use code below is shown here and "
"nowhere else, is not stored, and is regenerated on restart.\n"
" setup code: %s\n"
"After it is used the deployment refuses further setup attempts.",
token,
)
class SetupGate:
"""Tracks whether first-run setup is outstanding, and authorises one attempt.
One instance per process. The attempts counter is deliberately per process
rather than shared, because nothing durable is written: a restart resets it,
and a restart also regenerates the token, so the pair cannot be used to get
more attempts at the same secret.
"""
def __init__(self) -> None:
self._lock = threading.Lock()
self._token: str = ""
self._attempts = 0
self._spent = False
# -- lifecycle -----------------------------------------------------------
def begin_if_needed(self, *, platform_administrators: int) -> str | None:
"""Mint a token when setup is outstanding, and announce it to the journal.
Returns ``None`` when an administrator already exists, so a configured
deployment prints nothing and holds no secret.
The token has to reach the operator, and the log filter will not carry it
for them: :func:`umbratrans_web.security.redact_text` rewrites anything
shaped like ``<name>token<name> = value``, which is exactly the right
behaviour for a verification or session token and exactly wrong here.
Rather than weaken the filter, the announcement uses
:func:`announce_setup_token`, which is the single documented place in
the codebase that emits a secret to the log on purpose. The filter itself
is untouched, and a test pins both halves of that: this value gets
through, and ``token=`` assignments still do not.
"""
with self._lock:
if self._token or self._spent:
return None
if platform_administrators > 0:
self._spent = True
self._token = ""
return None
self._token = secrets.token_urlsafe(TOKEN_BYTES)
announce_setup_token(self._token)
return self._token
# -- state ---------------------------------------------------------------
@property
def is_outstanding(self) -> bool:
with self._lock:
return bool(self._token) and not self._spent
@property
def attempts_remaining(self) -> int:
with self._lock:
if not self._token:
return 0
return max(MAX_TOKEN_ATTEMPTS - self._attempts, 0)
def close(self) -> None:
"""Mark setup finished and drop the token from memory."""
with self._lock:
self._token = ""
self._spent = True
# -- authorisation -------------------------------------------------------
def consume(self, presented: str) -> None:
"""Validate a presented token, or raise.
Comparison is constant-time. A length or content difference must not be
observable in timing, because the whole purpose of the token is to be
unguessable and a timing oracle reintroduces guessing.
"""
with self._lock:
if not self._token or self._spent:
raise ConflictError(
"setup_not_required",
"First-run setup is not available on this deployment.",
)
if self._attempts >= MAX_TOKEN_ATTEMPTS:
self._token = ""
self._spent = True
raise ConflictError(
"setup_locked",
"Too many failed setup attempts. Restart the service to generate a new token.",
)
if not secrets.compare_digest(presented or "", self._token):
self._attempts += 1
remaining = max(MAX_TOKEN_ATTEMPTS - self._attempts, 0)
if remaining == 0:
self._token = ""
self._spent = True
raise ConflictError(
"setup_locked",
"Too many failed setup attempts. Restart the service to generate a new token.",
)
raise InputError(
"setup_token_invalid",
f"That setup token is not correct. {remaining} attempt(s) remain before this "
"token is discarded and the service must be restarted.",
)
# The token is spent the moment it authorises, whether or not the
# account creation that follows succeeds. Otherwise a failure part
# way through, such as a rejected password, would leave a token that
# has already been handed out still usable.
self._token = ""
self._spent = True
LOGGER.warning("first-run setup token accepted; no further setup attempts are possible")
__all__ = ["MAX_TOKEN_ATTEMPTS", "SetupGate", "RoleName"]

View File

@@ -28,6 +28,9 @@ def test_health_is_public_and_discloses_no_secret(client: TestClient) -> None:
assert body["service"] == "umbratrans-web"
assert body["environment"] == "test"
assert body["repository"] == "memory"
# An allowlist, so a new field has to be added deliberately. The mail and
# setup fields are booleans about the deployment's own readiness; none of
# them is a secret and none names a host, a path, or a count of accounts.
assert set(body) == {
"status",
"service",
@@ -36,6 +39,9 @@ def test_health_is_public_and_discloses_no_secret(client: TestClient) -> None:
"repository",
"cookie_secure",
"csrf_required",
"mail_deliverable",
"mail_delivery_waived",
"setup_required",
"time",
}
text = response.text.lower()

View File

@@ -0,0 +1,224 @@
"""Tests for the first-run setup endpoints.
These are the only unauthenticated endpoints that can create an account, so the
tests are about who can reach them rather than about what they return.
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from umbratrans_web.app import create_app
from umbratrans_web.config import Settings
from umbratrans_web.models import RoleName
from umbratrans_web.repository import InMemoryAccountRepository
from umbratrans_web.service import AccountService
from .conftest import provision_local, random_email
SETUP_PASSWORD = "Chosen-By-The-Operator-" + "x" * 12
def _client() -> tuple[TestClient, InMemoryAccountRepository]:
settings = Settings(environment="test")
repository = InMemoryAccountRepository()
service = AccountService(repository, settings)
return TestClient(create_app(settings=settings, repository=repository, service=service)), repository
def _token_from_log(caplog: pytest.LogCaptureFixture) -> str:
"""Pull the announced token out of the captured journal."""
for record in caplog.records:
message = record.getMessage()
for line in message.splitlines():
if "setup code:" in line:
return line.split("setup code:", 1)[1].strip()
raise AssertionError("no setup token was announced")
def test_status_reports_that_setup_is_required(caplog: pytest.LogCaptureFixture) -> None:
with caplog.at_level(logging_warning()):
client, _ = _client()
response = client.get("/api/setup/status")
assert response.status_code == 200
assert response.json() == {"setup_required": True}
def logging_warning() -> int:
import logging
return logging.WARNING
def test_setup_without_the_token_is_refused(caplog: pytest.LogCaptureFixture) -> None:
with caplog.at_level(logging_warning()):
client, _ = _client()
token = _token_from_log(caplog)
response = client.post(
"/api/setup/initial-administrator",
json={
"token": "x" * 40,
"email": random_email("intruder"),
"display_name": "Intruder",
"password": SETUP_PASSWORD,
},
)
# 422, because a refused token is an InputError and that is what InputError
# maps to everywhere else in this API. Consistency of the error shape matters
# more here than the specific code.
assert response.status_code == 422
assert response.json()["error"]["code"] == "setup_token_invalid"
# And nothing was created.
assert client.get("/api/setup/status").json()["setup_required"] is True
assert token # the real token was untouched by the failed attempt
def test_setup_with_the_token_creates_the_administrator(caplog: pytest.LogCaptureFixture) -> None:
with caplog.at_level(logging_warning()):
client, _ = _client()
token = _token_from_log(caplog)
email = random_email("first-admin")
response = client.post(
"/api/setup/initial-administrator",
json={
"token": token,
"email": email,
"display_name": "First Administrator",
"username": "First.Admin",
"password": SETUP_PASSWORD,
},
)
assert response.status_code == 201, response.text
body = response.json()
assert body["email"] == email
# The password was chosen by the caller, so it is never echoed back.
assert SETUP_PASSWORD not in response.text
assert body["user_id"]
# The account exists, is a platform administrator, and is usable immediately:
# the operator attests to the address, which is what makes setup possible
# before any mail is configured.
status = client.get("/api/setup/status").json()
assert status["setup_required"] is False
def test_the_created_administrator_can_sign_in(caplog: pytest.LogCaptureFixture) -> None:
with caplog.at_level(logging_warning()):
client, _ = _client()
token = _token_from_log(caplog)
email = random_email("signin-admin")
client.post(
"/api/setup/initial-administrator",
json={"token": token, "email": email, "display_name": "Sign In Admin", "password": SETUP_PASSWORD},
)
# Sign-in needs the CSRF cookie, which any safe request sets.
client.get("/api/health")
response = client.post("/api/auth/login", json={"email": email.upper(), "password": SETUP_PASSWORD})
assert response.status_code == 200, response.text
assert response.json()["user"]["role"] == RoleName.PLATFORM_ADMIN
def test_setup_closes_once_an_administrator_exists(caplog: pytest.LogCaptureFixture) -> None:
"""The gate is re-checked per request, so provisioning an admin closes it too.
This is the case that matters for an existing deployment: an operator who
already has an administrator does not get a second one, and no token is
printed for them in the first place.
"""
settings = Settings(environment="test")
repository = InMemoryAccountRepository()
service = AccountService(repository, settings)
provision_local(service, username="Existing.Admin", role=str(RoleName.PLATFORM_ADMIN))
with caplog.at_level(logging_warning()):
client = TestClient(create_app(settings=settings, repository=repository, service=service))
assert client.get("/api/setup/status").json() == {"setup_required": False}
assert "FIRST-RUN SETUP" not in caplog.text
response = client.post(
"/api/setup/initial-administrator",
json={
"token": "y" * 40,
"email": random_email("second-admin"),
"display_name": "Second Admin",
"password": SETUP_PASSWORD,
},
)
assert response.status_code == 409
assert response.json()["error"]["code"] == "setup_not_required"
def test_setup_rejects_a_password_the_policy_refuses(caplog: pytest.LogCaptureFixture) -> None:
"""The token is spent even though no account is created.
Otherwise a weak password would leave a token that was already shown to a
human still usable, which is a way to get unlimited attempts.
"""
with caplog.at_level(logging_warning()):
client, _ = _client()
token = _token_from_log(caplog)
refused = client.post(
"/api/setup/initial-administrator",
json={
"token": token,
"email": random_email("weak-password"),
"display_name": "Weak Password",
"password": "short",
},
)
assert refused.status_code == 422
# The gate is now closed even though nothing was created.
assert client.get("/api/setup/status").json()["setup_required"] is True
retry = client.post(
"/api/setup/initial-administrator",
json={
"token": token,
"email": random_email("second-try"),
"display_name": "Second Try",
"password": SETUP_PASSWORD,
},
)
assert retry.status_code == 409
assert retry.json()["error"]["code"] == "setup_not_required"
def test_the_status_endpoint_never_returns_the_token(caplog: pytest.LogCaptureFixture) -> None:
with caplog.at_level(logging_warning()):
client, _ = _client()
token = _token_from_log(caplog)
body = client.get("/api/setup/status").text
assert token not in body
assert set(client.get("/api/setup/status").json()) == {"setup_required"}
def test_the_setup_response_never_carries_account_enumeration_detail(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A wrong token and a right one must not be distinguishable in shape."""
with caplog.at_level(logging_warning()):
client, _ = _client()
token = _token_from_log(caplog)
wrong = client.post(
"/api/setup/initial-administrator",
json={"token": "z" * 40, "email": random_email("a"), "display_name": "A B", "password": SETUP_PASSWORD},
)
assert wrong.status_code == 422
right = client.post(
"/api/setup/initial-administrator",
json={"token": token, "email": random_email("b"), "display_name": "C D", "password": SETUP_PASSWORD},
)
assert right.status_code == 201
# The two differ in status because one worked. Neither reveals anything about
# the deployment beyond that, and the refusal names no account.
assert "account" not in wrong.text.lower()

View File

@@ -36,11 +36,93 @@ def test_load_settings_reads_the_development_environment() -> None:
def test_load_settings_refuses_unknown_environments() -> None:
# Not "staging": that is a real environment now, and it was briefly this
# test's example of an invalid value, which is exactly the kind of drift that
# makes a test quietly stop testing what it claims.
with pytest.raises(ConfigurationError) as error:
load_settings({"UMBRA_ENV": "staging"})
load_settings({"UMBRA_ENV": "preproduction"})
assert error.value.code == "invalid_environment"
def _public_environment_settings(environment: str, **overrides: str) -> dict[str, str]:
"""The minimum a publicly reachable environment needs, all of it satisfied."""
base = {
"UMBRA_ENV": environment,
"UMBRATRANS_REPOSITORY": "postgres",
"UMBRATRANS_DATABASE_DSN": "postgresql:///umbratrans?host=/run/postgresql&user=umbra",
"UMBRATRANS_COOKIE_SECURE": "true",
"UMBRATRANS_REQUIRE_CSRF": "true",
"UMBRATRANS_VERIFICATION_BASE_URL": "https://umbratrans.example.test",
"UMBRATRANS_ALLOWED_HOSTS": "umbratrans.example.test",
}
base.update(overrides)
return base
def test_staging_starts_when_the_mail_requirement_is_waived() -> None:
"""The bring-up path: everything else is as strict as production."""
settings = load_settings(_public_environment_settings("staging", UMBRATRANS_MAIL_DELIVERY_WAIVED="1"))
assert str(settings.environment) == "staging"
assert settings.mail_delivery_waived is True
assert settings.mail_is_deliverable is False
def test_staging_refuses_to_start_with_mail_waiver_absent() -> None:
with pytest.raises(ConfigurationError) as error:
load_settings(_public_environment_settings("staging"))
assert error.value.code == "staging_mailer_unconfigured"
def test_production_refuses_the_mail_waiver_outright() -> None:
"""The waiver is a bring-up convenience and cannot be used to skip mail in production.
Otherwise setting one environment variable would quietly remove the control
that matters most in the environment where it matters most.
"""
with pytest.raises(ConfigurationError) as error:
load_settings(
_public_environment_settings(
"production",
UMBRATRANS_MAIL_DELIVERY_WAIVED="1",
UMBRATRANS_MAILER="log",
)
)
assert error.value.code == "production_mailer_missing"
def test_staging_holds_every_other_production_rule() -> None:
"""The waiver buys exactly one thing: starting without SMTP.
Each of these is refused in staging for the same reason as in production, so
a staging deployment cannot be a convenient way to run something production
would not allow.
"""
cases = {
"insecure_production_cookie": {"UMBRATRANS_COOKIE_SECURE": "false"},
"insecure_production_csrf": {"UMBRATRANS_REQUIRE_CSRF": "false"},
"production_dsn_missing": {"UMBRATRANS_DATABASE_DSN": ""},
"production_repository_missing": {"UMBRATRANS_REPOSITORY": "json"},
"production_verification_url_missing": {"UMBRATRANS_VERIFICATION_BASE_URL": ""},
}
for expected_code, overrides in cases.items():
env = _public_environment_settings("staging", UMBRATRANS_MAIL_DELIVERY_WAIVED="1", **overrides)
with pytest.raises(ConfigurationError) as error:
load_settings(env)
assert error.value.code == expected_code, (expected_code, overrides)
def test_a_configured_mailer_counts_as_deliverable() -> None:
settings = load_settings(
_public_environment_settings("production", UMBRATRANS_MAILER="smtp", UMBRATRANS_SMTP_HOST="mail.example.test")
)
assert settings.mail_is_deliverable is True
assert settings.mail_delivery_waived is False
def test_load_settings_refuses_insecure_production() -> None:
with pytest.raises(ConfigurationError) as cookie_error:
load_settings({"UMBRA_ENV": "production", "UMBRATRANS_DATABASE_DSN": TEST_DSN})
@@ -177,11 +259,16 @@ def test_public_fingerprint_has_no_secret_values() -> None:
settings = Settings(environment=Environment.TEST, token_pepper=b"k" * 32)
fingerprint = settings.public_fingerprint()
assert "k" * 32 not in str(fingerprint)
# An allowlist: a new field has to be added here on purpose, which is the
# point. The mail fields report whether a transport is configured, never
# where it points or how it authenticates.
assert set(fingerprint) == {
"environment",
"repository",
"cookie_secure",
"csrf_required",
"mail_deliverable",
"mail_delivery_waived",
"session_idle_seconds",
"session_absolute_seconds",
"argon2",

View File

@@ -56,7 +56,7 @@ from umbratrans_web.postgres_repository import (
from umbratrans_web.repository import AccountRepository
from umbratrans_web.service import AccountService, OperatorContext
from .conftest import provision_invite, random_password, verification_token
from .conftest import provision_invite, provision_local, random_password, verification_token
TEST_DSN_ENV = "UMBRATRANS_TEST_DSN"
@@ -721,10 +721,19 @@ def test_a_role_resolves_through_user_roles(
def test_the_database_stores_only_an_argon2id_hash(
pg_service: AccountService, postgres: PostgresAccountRepository
) -> None:
"""A stored credential holds an Argon2id hash, a change flag, and nothing else.
Scoped to the account this test creates. An earlier version selected every
credential in the table, which made the result depend on the history of
whatever shared database the suite pointed at: it passed only while no
account had replaced its one-time password, and failed as soon as one had.
A test that asserts about another run's rows is not a test of this code.
"""
# Platform scope, because the operator path still assigns a role and every
# other role needs an organization. The tenant rules have their own tests;
# this one is only about what lands in user_credentials.
pg_service.create_account(
account = pg_service.create_account(
enrollment_mode="temporary_password",
username=f"Hash.Check.{uuid4().hex[:6]}",
display_name="Hash Check",
@@ -734,13 +743,51 @@ def test_the_database_stores_only_an_argon2id_hash(
operator=OperatorContext(source="operator-cli"),
)
with postgres._pool.connection() as connection: # noqa: SLF001 - test introspection
rows = connection.execute(
"SELECT password_hash, must_change_password FROM public.user_credentials WHERE password_hash IS NOT NULL"
).fetchall()
assert rows, "expected at least one credential"
for row in rows:
assert row["password_hash"].startswith("$argon2id$")
assert row["must_change_password"] is True
row = connection.execute(
"SELECT password_hash, must_change_password, credential_type "
"FROM public.user_credentials WHERE user_id = %s",
(account.id,),
).fetchone()
assert row is not None, "no credential row was written"
assert row["password_hash"].startswith("$argon2id$")
assert row["must_change_password"] is True
assert row["credential_type"] == "local_password"
@integration
def test_a_replaced_password_is_not_still_flagged_for_change(
pg_service: AccountService, postgres: PostgresAccountRepository
) -> None:
"""The flag clears when the password is replaced, in the store.
The other half of the property above, and the reason it has to be scoped:
a credential whose one-time password has been replaced must stop being marked
as one, or every later sign-in would be pushed back through the change flow.
"""
account = provision_local(pg_service, username=f"Replaced.{uuid4().hex[:6]}")
service = AccountService(pg_service.repository, pg_service.settings)
# Signed in first, because the change requires a principal resolved from a
# session. That is the real order: the one-time password produces a session
# flagged for change, and the change is made on that session.
result = service.authenticate(email=account.email, password=account.password)
assert result.session.must_change_password is True
principal = service.resolve_session(result.session.token)
replacement = random_password(30)
service.change_first_password(
principal,
current_password=account.password,
new_password=replacement,
)
with postgres._pool.connection() as connection: # noqa: SLF001 - test introspection
row = connection.execute(
"SELECT must_change_password, password_hash FROM public.user_credentials WHERE user_id = %s",
(account.user_id,),
).fetchone()
assert row is not None
assert row["must_change_password"] is False
assert row["password_hash"].startswith("$argon2id$")
@integration

View File

@@ -0,0 +1,130 @@
"""Tests for first-run setup.
The properties that matter are the ones an attacker would aim at: that setup
cannot be reached without the journal token, that it closes permanently once an
administrator exists, that the token is spent whether or not the account creation
that follows succeeds, and that the one place the codebase deliberately prints a
secret is the only such place.
"""
from __future__ import annotations
import logging
import pytest
from umbratrans_web.errors import ConflictError, InputError
from umbratrans_web.security import redact_text
from umbratrans_web.setup import MAX_TOKEN_ATTEMPTS, SetupGate
def test_a_token_is_minted_only_when_no_administrator_exists() -> None:
empty = SetupGate()
assert empty.begin_if_needed(platform_administrators=0)
assert empty.is_outstanding
configured = SetupGate()
assert configured.begin_if_needed(platform_administrators=1) is None
assert not configured.is_outstanding
def test_a_configured_deployment_prints_nothing(caplog: pytest.LogCaptureFixture) -> None:
"""No token, and no log line claiming one is needed."""
with caplog.at_level(logging.WARNING):
assert SetupGate().begin_if_needed(platform_administrators=3) is None
assert "FIRST-RUN SETUP" not in caplog.text
def test_the_token_is_announced_to_the_journal(caplog: pytest.LogCaptureFixture) -> None:
with caplog.at_level(logging.WARNING):
token = SetupGate().begin_if_needed(platform_administrators=0)
assert token is not None
assert token in caplog.text
def test_the_announcement_is_the_only_deliberate_secret_in_a_log_record() -> None:
"""The one exception, pinned from both sides.
The log filter rewrites anything shaped like ``name_token=value``, which is
what protects verification and session tokens. The setup code has to reach
the operator, so it is emitted without that shape. These two assertions are
what stop that from quietly becoming a general weakening of the filter: the
exception still works, and the rule it steps around still holds.
"""
assert redact_text("setup_token=abc123") == "setup_token=[redacted]"
assert redact_text("password: hunter2") == "password: [redacted]"
assert redact_text(" setup code: abc123") == " setup code: abc123"
def test_a_wrong_token_is_refused_and_costs_an_attempt() -> None:
gate = SetupGate()
token = gate.begin_if_needed(platform_administrators=0)
assert token is not None
with pytest.raises(InputError) as error:
gate.consume("not-the-token")
assert error.value.code == "setup_token_invalid"
assert gate.attempts_remaining == MAX_TOKEN_ATTEMPTS - 1
def test_too_many_failures_burn_the_token() -> None:
"""A guess that keeps going stops working, and restarting is the only way back."""
gate = SetupGate()
token = gate.begin_if_needed(platform_administrators=0)
assert token is not None
for _ in range(MAX_TOKEN_ATTEMPTS):
with pytest.raises((InputError, ConflictError)):
gate.consume("wrong")
# The correct token no longer helps, so a lucky guess after the limit is
# impossible rather than merely unlikely.
with pytest.raises(ConflictError) as error:
gate.consume(token)
assert error.value.code == "setup_not_required"
assert not gate.is_outstanding
def test_the_token_is_spent_by_a_correct_attempt() -> None:
"""Spent on authorisation, not on the account creation that follows.
Otherwise a failure part way through, such as a password the policy rejects,
would leave a token that has already been shown to a human still usable.
"""
gate = SetupGate()
token = gate.begin_if_needed(platform_administrators=0)
assert token is not None
gate.consume(token)
assert not gate.is_outstanding
with pytest.raises(ConflictError) as error:
gate.consume(token)
assert error.value.code == "setup_not_required"
def test_a_minted_token_cannot_be_minted_again() -> None:
gate = SetupGate()
first = gate.begin_if_needed(platform_administrators=0)
assert first is not None
assert gate.begin_if_needed(platform_administrators=0) is None
def test_tokens_are_long_and_unpredictable() -> None:
"""A token that can be guessed is not a token."""
tokens = {SetupGate().begin_if_needed(platform_administrators=0) or "" for _ in range(20)}
assert len(tokens) == 20, "tokens repeated"
for token in tokens:
assert len(token) >= 40, len(token)
def test_closing_the_gate_drops_the_token() -> None:
gate = SetupGate()
gate.begin_if_needed(platform_administrators=0)
gate.close()
assert not gate.is_outstanding
with pytest.raises(ConflictError):
gate.consume("anything")

View File

@@ -1639,6 +1639,7 @@
}
note.textContent = parts.length > 0 ? parts.join(' · ') : 'Service reachable.';
}
renderBanners(data);
},
function () {
var dot = document.getElementById('admin-health-dot');
@@ -1652,6 +1653,69 @@
}
);
/*
* Standing notices for a deployment that is not fully ready.
*
* These are not dismissible, and they are not decoration. Each one describes
* a state in which an action an administrator is about to take will not work
* as expected: an account created without a mail transport can never be
* activated, and a deployment with no administrator has no way to fix that
* from this page. Discovering either by waiting for an email that never
* arrives is the failure mode this replaces.
*/
function renderBanners(health) {
var host = document.getElementById('admin-banners');
if (!host) {
return;
}
while (host.firstChild) {
host.removeChild(host.firstChild);
}
var notices = [];
if (health && health.setup_required) {
notices.push({
tone: 'warning',
text: 'This deployment has no platform administrator. Open the setup page to create the first one.'
});
}
if (health && health.mail_deliverable === false) {
notices.push({
tone: 'danger',
text:
'Outbound mail is not configured, so confirmation links cannot be delivered and new accounts ' +
'cannot be activated. Account creation is disabled until SMTP is configured. ' +
(health.mail_delivery_waived
? 'The service was started with the mail requirement explicitly waived.'
: '')
});
}
if (notices.length === 0) {
host.hidden = true;
return;
}
notices.forEach(function (notice) {
var box = document.createElement('p');
box.className = 'admin-banner admin-banner--' + notice.tone;
box.setAttribute('role', 'status');
box.textContent = notice.text;
host.appendChild(box);
});
host.hidden = false;
}
// Accounts cannot be activated while mail is undeliverable, so the form is
// stopped at the point of use rather than after a round trip.
api.health().then(function (health) {
if (!health || health.mail_deliverable !== false) {
return;
}
var buttons = document.querySelectorAll('[data-open="provision-modal"]');
Array.prototype.forEach.call(buttons, function (button) {
button.disabled = true;
button.title = 'Account creation needs a configured mail transport.';
});
});
api.me().then(afterSession, function (error) {
if (error && (error.status === 401 || error.code === 'password_change_required')) {
// The registered handlers are already navigating away.

View File

@@ -21,6 +21,32 @@
var query = api.query;
var queryAll = api.queryAll;
/* -------------------------------------------------------- first-run setup */
var setupEntry = query('#setup-entry');
var setupLink = query('#setup-link');
/*
* Offer the setup page only when the service says it is outstanding.
*
* Asked rather than assumed, and a failed probe hides the link: advertising a
* setup page that is certain to refuse is worse than not offering it.
*/
function offerSetupIfRequired() {
api.request('/api/setup/status', { method: 'GET' }).then(
function (data) {
if (data && data.setup_required) {
api.show(setupEntry, true);
api.show(setupLink, true);
}
},
function () {
api.show(setupEntry, false);
api.show(setupLink, false);
}
);
}
/* ------------------------------------------------------------- sign-in */
function setMessage(node, text, tone) {
@@ -554,4 +580,5 @@
setupSignIn();
setupWorkspace();
offerSetupIfRequired();
})(window);

178
web/assets/setup.js Normal file
View File

@@ -0,0 +1,178 @@
/*
* First-run setup.
*
* The only page that can create an account without already being signed in, and
* only while the service reports that setup is outstanding. The setup code is
* never fetched: the server has no endpoint that returns it, and it is read by
* the operator from the service log and pasted here.
*
* No inline script or style, to stay inside the content security policy
* (`script-src 'self'; style-src 'self'`).
*/
(function (global) {
'use strict';
var api = global.UmbraApi;
if (!api) {
return;
}
var query = api.query;
var formView = query('#setup-form-view');
var unavailable = query('#setup-unavailable');
var form = query('#setup-form');
var message = query('#setup-message');
var submit = query('#setup-submit');
var toggle = query('#toggle-setup-password');
var tokenInput = query('#setup-token');
var emailInput = query('#setup-email');
var displayInput = query('#setup-display-name');
var usernameInput = query('#setup-username');
var passwordInput = query('#setup-password');
var policyLength = query('#setup-policy-length');
var policyUsername = query('#setup-policy-username');
function setMessage(text, tone) {
if (!message) {
return;
}
message.textContent = text;
message.className = 'form-message' + (tone ? ' form-message--' + tone : '');
}
function show(view) {
if (formView) {
formView.hidden = view !== 'form';
}
if (unavailable) {
unavailable.hidden = view !== 'done';
}
}
if (toggle && passwordInput) {
toggle.addEventListener('click', function () {
var reveal = passwordInput.type === 'password';
passwordInput.type = reveal ? 'text' : 'password';
toggle.setAttribute('aria-pressed', reveal ? 'true' : 'false');
toggle.textContent = reveal ? 'Hide' : 'Show';
});
}
function describePolicy() {
if (policyLength) {
var value = passwordInput ? passwordInput.value : '';
var long = value.length >= 14;
policyLength.textContent = (long ? 'At least 14 characters. ' : 'At least 14 characters. ') +
(value ? (long ? 'Met.' : value.length + ' so far.') : '');
}
if (policyUsername) {
var handle = usernameInput ? usernameInput.value.trim() : '';
policyUsername.textContent = handle
? 'Must not contain “' + handle + '”.'
: 'Must not contain your account name.';
}
}
if (passwordInput) {
passwordInput.addEventListener('input', describePolicy);
}
if (usernameInput) {
usernameInput.addEventListener('input', describePolicy);
}
describePolicy();
if (form) {
form.addEventListener('submit', function (event) {
event.preventDefault();
var token = tokenInput ? tokenInput.value.trim() : '';
var email = emailInput ? emailInput.value.trim() : '';
var displayName = displayInput ? displayInput.value.trim() : '';
var username = usernameInput ? usernameInput.value.trim() : '';
var password = passwordInput ? passwordInput.value : '';
if (!token) {
setMessage('Paste the setup code from the service log.', 'error');
return;
}
if (!email || email.indexOf('@') === -1) {
setMessage('Enter the email address the administrator will sign in with.', 'error');
return;
}
if (!displayName) {
setMessage('Enter a display name.', 'error');
return;
}
if (!password) {
setMessage('Choose a password.', 'error');
return;
}
// The code is cleared before the request is sent, not after it succeeds.
// If the response says the attempt was refused, the code is already gone
// from the form and the operator has to go back to the log for whatever
// remains, which is the intended friction.
if (tokenInput) {
tokenInput.value = '';
}
api.disable(submit, true);
setMessage('Creating the administrator…', null);
api.request('/api/setup/initial-administrator', {
method: 'POST',
body: {
token: token,
email: email,
display_name: displayName,
username: username || null,
password: password
}
}).then(
function (data) {
api.disable(submit, false);
if (passwordInput) {
passwordInput.value = '';
}
setMessage(data && data.message ? data.message : 'Administrator created.', 'success');
show('done');
},
function (error) {
api.disable(submit, false);
if (passwordInput) {
passwordInput.value = '';
}
setMessage(api.describeError(error), 'error');
// The gate is gone once it has been spent or burnt, so stop offering
// the form rather than letting the operator keep submitting into a
// refusal.
if (error && (error.code === 'setup_not_required' || error.code === 'setup_locked')) {
show('done');
return;
}
if (tokenInput) {
tokenInput.focus();
}
}
);
});
}
// Ask the service whether setup is still outstanding, rather than assuming it.
api.request('/api/setup/status', { method: 'GET' }).then(
function (data) {
if (data && data.setup_required) {
show('form');
if (tokenInput) {
tokenInput.focus();
}
} else {
show('done');
}
},
function () {
// If the status cannot be read the form is not offered. Showing it anyway
// would present a form whose every submission is certain to fail.
show('done');
}
);
}(window));

View File

@@ -80,6 +80,14 @@ button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible
.brand-points > div { display: flex; align-items: center; gap: .65rem; }
.point-dot { width: .45rem; height: .45rem; border-radius: 50%; background: var(--workspace-teal); box-shadow: 0 0 0 .3rem rgba(45, 212, 191, .12); }
.prototype-note { margin: 0; color: #64748b; font-size: .72rem; letter-spacing: .04em; }
/* Standing deployment notices. Never dismissible: each one describes a state
in which an action about to be taken will not work. */
.admin-banners { display: grid; gap: .6rem; margin: 0 0 1.4rem; }
.admin-banner { margin: 0; padding: .85rem 1rem; border-radius: .6rem; font-size: .8rem; line-height: 1.55; border: 1px solid; }
.admin-banner--warning { color: #fde68a; background: rgba(120, 53, 15, .45); border-color: rgba(253, 230, 138, .35); }
.admin-banner--danger { color: #fecaca; background: rgba(127, 29, 29, .45); border-color: rgba(254, 202, 202, .32); }
.admin-banner[hidden] { display: none; }
.auth-card { padding: 2rem; background: rgba(22, 33, 62, .88); border: 1px solid var(--admin-border); border-radius: 1rem; box-shadow: var(--shadow); backdrop-filter: blur(14px); }
.auth-card-heading h2 { margin: .75rem 0 .55rem; color: #fff; font-size: 1.6rem; letter-spacing: -.03em; }
.auth-card-heading p { margin: 0 0 1.7rem; color: var(--admin-muted); font-size: .86rem; line-height: 1.6; }

View File

@@ -52,6 +52,8 @@
<button id="resend-verification" class="button button--ghost" type="button">Send a new link</button>
<div id="verify-panel-message" class="form-message" role="status" aria-live="polite"></div>
</div>
<div id="setup-entry" class="auth-divider" hidden><span>First run</span></div>
<a id="setup-link" class="admin-link" href="setup.html" hidden><span>Set up this deployment</span><span aria-hidden="true">↗</span></a>
<div id="admin-entry" class="auth-divider"><span>Administrator?</span></div>
<a id="admin-link" class="admin-link" href="uadmin/"><span>Open control panel</span><span aria-hidden="true">↗</span></a>
<p class="auth-footnote">An account issued a temporary password must replace it at first sign-in before any workspace access is granted.</p>

80
web/setup.html Normal file
View File

@@ -0,0 +1,80 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Create the first UmbraTrans administrator">
<meta name="referrer" content="same-origin">
<title>Set up UmbraTrans</title>
<link rel="icon" type="image/png" href="assets/umbratrans-emblem.png">
<link rel="stylesheet" href="assets/styles.css">
</head>
<body class="auth-page">
<main class="auth-shell">
<section class="auth-brand" aria-labelledby="brand-title">
<a class="brand-lockup" href="index.html" aria-label="UmbraTrans home">
<img class="brand-image" src="assets/umbratrans-emblem.png" alt="" aria-hidden="true">
<span>UmbraTrans</span>
</a>
<div class="brand-copy">
<p class="eyebrow">First run</p>
<h1 id="brand-title">Create the first administrator.</h1>
<p class="brand-lede">This deployment has no administrator yet. The account you create here is the one that can then create organizations, enroll accounts, and audit everything that follows.</p>
</div>
</section>
<section class="auth-card" aria-labelledby="setup-title">
<div id="setup-unavailable" hidden>
<div class="auth-card-heading">
<span class="status-pill status-pill--success">Already set up</span>
<h2 id="setup-title">This deployment is configured</h2>
<p>A platform administrator already exists, so first-run setup is closed. If you need another account, sign in to the control panel and create it there.</p>
</div>
<a class="admin-link" href="index.html"><span>Back to sign in</span><span aria-hidden="true">↗</span></a>
</div>
<div id="setup-form-view" hidden>
<div class="auth-card-heading">
<span class="status-pill status-pill--warning">Action required</span>
<h2 id="setup-title">First-run setup</h2>
<p id="setup-intro">Paste the single-use setup code from the service log, then choose the administrator's email address and password.</p>
</div>
<form id="setup-form" class="stack-form" novalidate>
<label for="setup-token">Setup code</label>
<input id="setup-token" name="token" type="text" autocomplete="off" autocapitalize="none" autocorrect="off" spellcheck="false" required>
<p class="field-hint">Written to the service log when the service started. Shown once, and not stored.</p>
<label for="setup-email">Email address</label>
<input id="setup-email" name="email" type="email" inputmode="email" autocomplete="username" autocapitalize="none" autocorrect="off" spellcheck="false" required>
<p class="field-hint">This is the address the administrator signs in with. Other accounts are created for their own addresses.</p>
<label for="setup-display-name">Display name</label>
<input id="setup-display-name" name="display_name" type="text" autocomplete="name" maxlength="120" required>
<label for="setup-username">Account name <span class="field-optional">optional</span></label>
<input id="setup-username" name="username" type="text" autocomplete="off" autocapitalize="none" spellcheck="false" maxlength="64">
<p class="field-hint">A display handle. Sign-in uses the email address, not this.</p>
<label for="setup-password">Password</label>
<div class="password-field">
<input id="setup-password" name="password" type="password" autocomplete="new-password" required>
<button id="toggle-setup-password" class="password-toggle" type="button" aria-pressed="false" aria-controls="setup-password">Show</button>
</div>
<ul class="policy-list" id="setup-policy">
<li id="setup-policy-length">At least 14 characters.</li>
<li id="setup-policy-username">Must not contain your account name.</li>
</ul>
<div id="setup-message" class="form-message" role="status" aria-live="polite"></div>
<button id="setup-submit" class="button button--primary button--wide" type="submit">Create administrator <span aria-hidden="true">→</span></button>
</form>
</div>
<p class="auth-footnote">The setup code authorises exactly one account and is discarded as soon as it is used, whether or not that account is created. Failed attempts are counted.</p>
</section>
</main>
<script src="assets/api.js" defer></script>
<script src="assets/setup.js" defer></script>
</body>
</html>

View File

@@ -44,6 +44,13 @@
</aside>
<main class="admin-main" id="admin-main">
<!-- Deployment banners -------------------------------------------- -->
<!-- Rendered by admin.js from /api/health. Shown only when the deployment
is not fully ready, and never dismissible, because each one means
something an administrator would otherwise have to discover by
waiting for something that will not happen. -->
<div id="admin-banners" class="admin-banners" hidden></div>
<!-- Dashboard ---------------------------------------------------- -->
<section class="admin-view" id="view-dashboard" data-view="dashboard">
<div class="admin-heading">