Files
app/backend/fluksio/cloud/enroll.py
T
stroblmeandClaude Opus 5 57eace2226 Bound what the API accepts, and close the holes the audit found
**SQLite is the database, and now says so.** `metric_minute` and every run
table are written with `sqlalchemy.dialects.sqlite.insert(...)
.on_conflict_do_update` and with `max(a, b)`, neither of which another
dialect has — so pointing `DATABASE_URL` at Postgres migrated cleanly,
served, logged in, and then lost every observability flush into the
collector's hold buffer and failed every run. It refuses at startup
instead. (The Postgres in the compose stack is Umami's; the engine's own
database has been a file beside the flows since 2026-08-21.)

**Every integer query parameter is bounded.** The caps were written as
`min(limit, 500)`, which a negative walks straight through — `?limit=-1`
compiles to `LIMIT -1` and SQLite returns the whole table. Ten signatures,
now `Query(ge=…, le=…)`. `hours=0` still means an hour, which
`_window_hours` was already deliberate about.

**Exports are capped at 10 000 runs** and say so with `X-Truncated`. The
filters bounded a sensible request and nothing bounded an unfiltered one,
which read every row into memory before a byte was streamed. `_series`
resolves cached curves in two queries rather than a `Run` lookup and a
`RunMetric` query per restored node — a comparison of twenty runs was
calling that twenty times over.

**`PUT /artifacts` has a size limit** (`MAX_ARTIFACT_BYTES`, 2 GiB, 0 to
disable), checked against `Content-Length` and again against the stream for
a chunked body, and its writes moved off the event loop.

**`/observability/timeseries` takes `since`/`until`**, the same window
`/runs` and `/events` take, capped at 2000 points — `hours=720&bucket_s=60`
was 43 200 of them in one array. It is also what a dragged chart needs to
re-fetch at its own resolution rather than magnifying buckets it has.

**Composite indexes** for the three list screens: `run(flow, created_at)`
and `(status, created_at)`, `flow_run(flow, started_at)`,
`engine_event(type, ts)`. Every index was single-column, so SQLite picked
one and sorted the rest by hand. Verified against a copy of a live database
(250k `flow_run` rows): the planner takes all four.

**Redis clients have socket timeouts.** A Redis that stops answering
without closing the connection hung the caller until the kernel gave up —
including `/utils/health/`, whose job is to notice.

**The panels file is written under one lock.** `save_panels` and
`unpair_panel` are both read-modify-write, and a save that read before an
unpair wrote put the old nonce back — silently un-revoking a screen that
had just been unpaired. The nonce carry-forward was written to make that
impossible; the gap between its read and its write is where it happened.

**Startup releases what it acquired.** Everything past `event_bus.bind`
registers how to close itself and the `finally` walks that list backwards;
a failure part-way through used to reach none of the shutdown steps and
leave the worker pool's subprocesses and every background task behind —
under `--reload`, once per bad edit. `modules.reconcile` moved into the
background: `uv` gets five minutes twice over, the healthcheck allows
eighty seconds, and the autoheal restarted the container before it could
finish installing.

`delete_run` takes SQLite's write lock up front (`core.db.writing`) rather
than upgrading a deferred transaction and losing to whichever flush
committed in between. `modules.sync` is serialised — two applies mutated
one venv at once. The proxied-call and stream dicts are bounded, and a
reused id cancels its predecessor instead of dropping the reference.

Security, found in passing and small enough to fix here:

- **`/secrets/` required only a signed-in user.** The names alone say what
  this installation talks to, and `PUT /{name}` takes any name, so any
  account could overwrite the credential a flow authenticates with.
  Superuser now — which `/search` already assumed and said so.
- **`POST /login/access-token` had no rate limit.** Argon2 is deliberately
  expensive and the route is unauthenticated and runs in the shared
  threadpool. Ten *failed* attempts per address per five minutes; a
  successful sign-in spends nothing.
- **a password reset link worked repeatedly for 48 hours.** The token now
  carries a digest of the password hash it was minted against, so it stops
  verifying once it has set one. No table of spent tokens needed.
- **enrolment accepted `http://`**, sending the claim code and then this
  installation's credential in clear. https, or a local address.
- the rate limiter read `request.client.host`, which behind Traefik is the
  proxy — so every per-address limit was one global bucket and one caller
  could lock out everyone. It reads the forwarded address, and its
  bucket table is capped rather than growing one key per address forever.
- SMTP has a timeout and sends after the response, so an unreachable mail
  host cannot pin a threadpool worker, and a reply's timing no longer says
  whether the address exists.

Test suite: engine-written rows are cleared between modules. A `FlowRun`
left `running` by one module turned up in another's query. Per-test
rollback is not available here — the module-scoped `client` runs the real
lifespan and its collector and run service write through sessions of their
own — so this bounds it where the writes come from. Three consecutive
green runs, orders randomised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6hPWS6YEbT1P8LxhhFb2T
2026-08-29 20:40:05 +02:00

119 lines
4.4 KiB
Python

"""Redeeming a claim code, from the dashboard or from the command line.
The work is the same either way — ask the portal, keep what it answers, and
map the account that asked to the portal identity that owns the installation —
so it lives here rather than in the route. The command line matters because a
machine on a cluster has no browser pointed at it: `fluksio enroll` does this
before the engine has started, holding nothing but the database.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from urllib.parse import urlsplit
import httpx
from sqlmodel import Session, select
import fluksio
from fluksio.cloud import config as cloud_config
from fluksio.models import User
class EnrollError(Exception):
"""A failure with the status the API should answer with."""
def __init__(self, status: int, detail: str) -> None:
super().__init__(detail)
self.status = status
self.detail = detail
class AlreadyEnrolled(EnrollError):
def __init__(self) -> None:
super().__init__(409, "This installation is already connected to a portal")
def _is_local(url: str) -> bool:
"""Whether the address is this machine or a compose-internal service."""
host = urlsplit(url).hostname or ""
return host in {"localhost", "127.0.0.1", "::1"} or host.endswith(".local")
def redeem_claim(
portal_url: str, claim_code: str, *, timeout: float = 15.0
) -> dict[str, Any]:
"""Trade a claim code for this installation's credential and the portal's keys."""
base = portal_url.rstrip("/")
# The claim code and, from here on, this installation's credential go to
# this address. Over plain http both are readable by anything on the path,
# so refuse rather than enrol insecurely — bar a loopback portal, which is
# how the stack is developed against itself.
if not base.startswith("https://") and not _is_local(base):
raise EnrollError(
422, "The portal address must be https:// (or a local address)"
)
try:
response = httpx.post(
f"{base}/api/v1/enroll/",
json={"claim_code": claim_code, "app_version": fluksio.__version__},
timeout=timeout,
)
except httpx.HTTPError as exc:
raise EnrollError(502, f"Could not reach the portal: {exc}") from exc
if response.status_code == 404:
raise EnrollError(400, "That claim code is unknown or has expired")
if response.status_code != 200:
raise EnrollError(502, f"The portal refused the claim ({response.status_code})")
data: dict[str, Any] = response.json()
if not data.get("owner_id"):
# A portal older than remote users does not say who owns the
# installation, and without that the enrolling account cannot be mapped
# to anyone — which would leave the portal connected but refused here.
raise EnrollError(
502,
"That portal is too old for this installation: it did not say "
"which account owns the installation",
)
return data
def enroll(
session: Session, user: User, portal_url: str, claim_code: str
) -> cloud_config.CloudConfig:
"""Redeem the code and write the config the connector dials with."""
if cloud_config.exists():
raise AlreadyEnrolled()
data = redeem_claim(portal_url, claim_code)
config = cloud_config.CloudConfig(
portal_url=portal_url.rstrip("/"),
ws_url=data["ws_url"],
installation_id=data["installation_id"],
token=data["installation_token"],
issuer=data["issuer"],
# Pinned here, at the one moment the claim code proves who we are
# talking to. Nothing refreshes this.
jwks=data["jwks"],
local_user_id=str(user.id),
enrolled_at=datetime.now(UTC).isoformat(),
portal_account=user.email,
)
cloud_config.save(config)
owner_id = str(data["owner_id"])
# Re-enrolling from a different local account moves the mapping rather than
# leaving two accounts claiming the same portal identity, which the unique
# index would refuse and the lookup could not choose between anyway.
for other in session.exec(
select(User).where(User.portal_sub == owner_id, User.id != user.id)
):
other.portal_sub = None
session.add(other)
user.portal_sub = owner_id
session.add(user)
session.commit()
return config