Step #5429 "Reset by email: request, link and new-password page" of
milestone 497 "Passwords: change your own, and reset it by email".
- Sign-in page: "Forgot your password?" while a mail server is set.
- /forgot takes a username or email and answers the same either way; only
an active local account with a real address gets mail. Requests count
per address on their own throttle, so they never lock out sign-in.
- password_resets (migration 0039): a one-time key, stored as its sha256,
working for an hour; asking again replaces it.
- /reset/<key>: the new password, then signed straight in; every other
browser is signed out (sessions_since). Bad keys count as invite keys do.
- Email "[Steward] Reset your password", in the account's own zone.
- Audited: user.password_reset_requested, user.password_reset_by_email.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The operator's ask: an invite link sent by mail, the key in the URL, and
the new user choosing their own username, password and email.
- user_invites (migration 0037): the key's sha256 only, the address, the
role and days the admin chose, who sent it, used/revoked.
- core/invites: create (revokes an older open invite to the address),
renew (new key, new clock), find_pending, the four states, signup checks.
- /invite/<key>, outside sign-in and throttled like login: the invitee's
username, email (prefilled, theirs to change) and password; the account
takes the admin's role, the key is used up in the same transaction and
they are signed in. A bad key gets one plain message.
- Settings › Users: Invite by email (address, role, 1/7/30 days), sent from
the admin in their zone; without a mail server, or if sending fails, the
link is shown once to copy. Invites not yet used are listed with Resend
and Withdraw.
- Audit: user.invited, user.invite_resent, user.invite_revoked, user.joined.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#5036 "SSO and LDAP: users keep their auth source, no merging into local
accounts, is_active checked, LDAP TLS real and filter escaped".
- users gain auth_source (local/ldap/oidc) and external_id (migration 0027).
A provider account is found by (source, subject) only: the LDAP entry DN
or the OIDC sub. A first sign-in whose username or email another account
holds is refused with a message naming why; a disabled user stays out.
- Local passwords sign in local accounts only.
- LDAP: port 636 is LDAPS, otherwise StartTLS before any bind; the server
certificate is checked against the system CAs or a CA pasted in
Settings -> Auth. The login name is escaped into the filter (RFC 4515),
and an empty password is refused before the directory sees it.
- OIDC: the ID token must carry this sign-in's nonce and the userinfo sub.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#5035 "Sessions: lifetime applied, role and is_active re-read per request,
logout is a POST, failed logins throttled".
- steward/core/proxy.py: trusted_value / is_https / client_ip read
X-Forwarded-Proto and -For only as far as the trusted proxy hops
(Settings → General, default 1, 0 = trust none).
- The session cookie is Secure, and HSTS is sent, only on requests that
arrived over HTTPS by that rule; plain-HTTP and LAN use are unchanged
and nothing redirects.
- A login lasts Settings → General's session lifetime from when it began
(session.permanent + a login_at stamp checked every request — the
cookie's own age slides on every response). A deleted or deactivated
user is logged out on their next request; a role change applies at once.
- Logout is a POST (a form in the sidebar); GET /logout is gone.
- Failed logins pause an address after login.max_failures within
login.lockout_minutes (Settings → General; defaults 5 in 15 min),
keyed by address so nobody can lock a user out by name. Failures are
audited as auth.login_failed with the address.
- to_access_cfg projects the new settings into app.config at startup and
on save, beside the existing projections.
- Integration tests seed real users and a login_at, since every request
now re-reads the session's user.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#5033 "Share links open only their own dashboard; every route without a
role is on an explicit allow-list". Any live share token in ?s= made the
caller a viewer on every viewer route — host pages, container logs, any
dashboard — and the host-agent widget routes had no role check at all.
- steward/dashboard/share.py: a share token opens, read-only, exactly the
widget fragment URLs its own dashboard loads — same path, same
configured parameters; only the time window (hours/range) may change.
- require_role marks its view; public_route marks the deliberately open
ones (login, setup, health, the share page, agent ingest, install.sh,
agent.py) with the reason. A before_request refuses any view with
neither, so a route that forgets its decorator is closed, not open.
- /plugins/host_agent/widget and /widget/history now require viewer.
- The history widget's range buttons keep the rest of the query (and so
a share link's token) instead of rebuilding it; the uptime widget's
"Full SLA" link shows to logged-in users, not to share viewers.
- tests/test_route_access.py: a source scan that fails on any unmarked
route in core or bundled plugins, the default-deny refusal, and the
share matcher's cases.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a session has expired, require_role now bounces to /login?next=<path> and
sends the user back there after re-login.
- middleware: safe_next_url() (same-site relative path/query only; rejects
off-site, protocol-relative, javascript:, and the auth pages — no open
redirect). _login_redirect() builds the next param; for HTMX requests it uses
HX-Current-URL (the page, not the fragment) and HX-Redirect so the whole
browser navigates instead of swapping the login page into a fragment.
- login GET/POST carry `next` (hidden field), validated, used on success for
local + LDAP; OIDC stashes it in session across the IdP round-trip.
- login.html: hidden next field + next on the SSO link.
- tests for safe_next_url.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A fresh install had no prompt for general.public_base_url, so first-run agent
installs/share links silently used the request Host header. Add a "Steward URL"
field to the first-run /setup page (create-admin), pre-filled with the current
address, with help text. setup_post saves general.public_base_url and applies
it to app.config immediately (no restart). Editable later in Settings → General.
Scribe #896.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Renames the Python package directory, CLI command, env var prefix,
docker-compose service/container/image, Postgres role/db, and all
visible branding. Marketing form is "Fabled Steward".
Clean break from the previous rebrand: drops the fabledscryer→roundtable
import shim in __init__.py and the FABLEDSCRYER_* env var fallback in
config.py and migrations/env.py. Env vars are now STEWARD_* only.
Heads-up for existing deployments:
- Postgres user/db renamed fabledscryer → steward in docker-compose.yml.
Existing volumes need the role/db renamed inside Postgres, or override
POSTGRES_USER/POSTGRES_DB to keep the old names.
- Host-agent systemd unit is now steward-agent.service. Existing agents
keep running under the old name; reinstall to switch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>