Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 44 additions & 2 deletions keel/web/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
from typing import TYPE_CHECKING, Any

from keel.web import payload
from keel.web.security import csrf_token

if TYPE_CHECKING: # pragma: no cover - typing only
from keel.web.server import ServeConfig
Expand Down Expand Up @@ -157,15 +158,24 @@ def read_status(cfg: ServeConfig, _query: Query, _state: Any, now_ts: int) -> di
def read_setup(cfg: ServeConfig, _query: Query, state: Any, _now_ts: int) -> dict[str, Any]:
"""The first-run checklist -- the ONE deployment-reading endpoint that must answer when there
is no deployment, because it is the thing that says how to make one. `needs_database=False`
for that reason, and `server.needs_database` serves the same page in HTML for the same one.
for that reason: an endpoint that refused to answer until a deployment existed would refuse
precisely the operator who has none.

`state` is the `DeploymentState` the envelope already read for `engine`, passed in rather than
re-inspected: one 3.6 ms probe per response, not two."""
from keel.commands import jobs
from keel.commands.setup import ACTIONS, NOT_AUTOMATED_YET

return payload.setup_payload(
state, actions=ACTIONS, not_automated=NOT_AUTOMATED_YET, job=jobs.status()
state,
actions=ACTIONS,
not_automated=NOT_AUTOMATED_YET,
job=jobs.status(),
# The write token for this session, on the one endpoint whose view performs writes. Scoped
# to that endpoint rather than put on `/api/config` (which every view reads at boot) so it
# travels only to the page that needs it -- see `payload.setup_payload` for why it is in a
# body at all, which was a reversal.
csrf=csrf_token(cfg.token),
)


Expand Down Expand Up @@ -607,6 +617,38 @@ def refusal_document(status: int, title: str, detail: str) -> dict[str, Any]:
return payload.error_envelope(int(time.time()), status=status, title=title, detail=detail)


def action_document(result: Any) -> dict[str, Any]:
"""One completed setup action, as JSON (#540).

**`changed` is the field the client actually needs**, and it is not a success flag: it is the
difference between "created" and "already there". `keel.commands.setup`'s own note on it calls
it "the property that makes a double-click safe" -- every action is idempotent, so a repeated
submission succeeds and reports `changed: false`, which is a true statement about the
deployment rather than a soft failure.

Judged here, in the serialiser, exactly as every other payload value is: the client is handed
`display` and `state` and never decides what a result means.
"""
return payload.envelope(
int(time.time()),
# `running=True` is a statement of fact rather than a probe: this document is built only
# after an action has RUN in this process, so the engine's presence is not in question and
# re-inspecting the deployment to say so would be one disk probe spent on a known answer.
running=True,
data={
"step_key": str(getattr(result, "step_key", "")),
"changed": payload.flag(
bool(getattr(result, "changed", False)),
on="done",
off="already done — nothing to change",
on_state=payload.GOOD,
off_state=payload.NEUTRAL,
),
"message": payload.label(str(getattr(result, "message", ""))),
},
)


def sortable_columns() -> Mapping[str, Sequence[str]]:
"""The declared sort surface, for a test to read rather than restate."""
return {path: route.sortable for path, route in API_ROUTES.items() if route.sortable}
40 changes: 35 additions & 5 deletions keel/web/payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -1479,6 +1479,16 @@ def _action_input_payload(field: Any) -> dict[str, Any]:
# a form that can be submitted safely and one that leaks its own contents into browser
# history. The client is told the answer rather than deriving it from the field's name.
"secret": flag(field.secret, on="never echoed back", off="shown as typed"),
# The SAME fact as `secret`, in the form the client needs to act on rather than to show.
#
# Two keys for one boolean looks like duplication and is the opposite: `secret` is a
# `Field`, and a `Field` is a thing the client DISPLAYS -- Rule 3 of the client's pins
# forbids `render.js` from reading `.value` at all, precisely so no view can start
# deciding what a payload means. Choosing between `type="password"` and `type="text"` is
# not a judgement about a value, it is a rendering instruction, and the server is the one
# that gives it. Without this the client would have had to read `secret.value`, which is
# the whole rule.
"kind": "secret" if field.secret else "text",
# A closed set of answers, rendered with NOTHING pre-selected: an action that could fill in
# a field the operator left blank is one that could record something they never supplied.
"choices": list(field.choices),
Expand Down Expand Up @@ -1526,22 +1536,42 @@ def setup_payload(
actions: Sequence[Any] = (),
not_automated: Mapping[str, str] | None = None,
job: Any = None,
csrf: str = "",
) -> dict[str, Any]:
"""`keel.commands.setup.inspect`'s `DeploymentState`, as JSON.

**No CSRF token, and that is the point rather than an omission.** `render_setup` takes one
because it emits `<form method=post>`; this issue ships reads only, and minting a live write
credential into a GET response would put it into every cached copy, every proxy log and every
paste of "here is what the API returned". The token is `csrf_token(session)` and it stays where
the write is.
**`csrf` is here since #540, and an earlier revision of this docstring argued it should not
be.** That argument is recorded rather than deleted, because reversing it was a decision:

"No CSRF token, and that is the point rather than an omission. `render_setup` takes one
because it emits `<form method=post>`; this issue ships reads only, and minting a live
write credential into a GET response would put it into every cached copy, every proxy log
and every paste of 'here is what the API returned'."

Two of those three concerns were already answered by the time the form was deleted: `/api/*`
is `Cache-Control: no-store` and #538's service worker refuses to cache it, so there is no
cached copy; and the server binds loopback, so there is no proxy. The third -- an operator
pasting API output into an issue -- is real, and it is what settles the question rather than
what blocks it: **this token is not a credential on its own.** It authorises nothing without
the session cookie, and anyone holding that cookie can read this endpoint and mint the same
token for themselves. A paste of it grants exactly what a paste of a random hex string grants.

What the alternative would have cost is the reason not to be clever here: delivering it in a
second, script-readable cookie would take a live token out of a body and put it into
`document.cookie`, which is a strictly worse place for it on an origin whose session cookie is
deliberately `HttpOnly`.

`actions` and `not_automated` are read from `keel.commands.setup`'s own closed registries and
copied, never filtered here: an action appears only where the registry carries one, which is
what stops "attest this asset" appearing because somebody edited a front-end.

"""
from keel.commands.setup import Stage

return {
# A bare string, not a `Field`: it is a credential the client SENDS, never a value it
# displays, and giving it a `display` would invite exactly that.
"csrf": str(csrf),
"root": str(state.root),
"config_path": str(state.config_path),
"db_path": str(state.db_path),
Expand Down
Loading
Loading