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:
129
docs/umbratrans-deployment.md
Normal file
129
docs/umbratrans-deployment.md
Normal 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.
|
||||
76
systemd/umbratrans-web.service
Normal file
76
systemd/umbratrans-web.service
Normal 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
|
||||
79
systemd/umbratrans.env.example
Normal file
79
systemd/umbratrans.env.example
Normal 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.
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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:
|
||||
|
||||
139
umbratrans_web/routes_setup.py
Normal file
139
umbratrans_web/routes_setup.py
Normal 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"]
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
195
umbratrans_web/setup.py
Normal 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"]
|
||||
@@ -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()
|
||||
|
||||
224
umbratrans_web/tests/test_api_setup.py
Normal file
224
umbratrans_web/tests/test_api_setup.py
Normal 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()
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
130
umbratrans_web/tests/test_setup.py
Normal file
130
umbratrans_web/tests/test_setup.py
Normal 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")
|
||||
@@ -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.
|
||||
|
||||
@@ -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
178
web/assets/setup.js
Normal 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));
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
80
web/setup.html
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user