feat(users): invite by email — a one-time link where the invitee makes their own account (milestone 496)
CI / lint (push) Successful in 3s
CI / integration (push) Failing after 1m17s
CI / unit (push) Successful in 1m43s
CI / publish (push) Skipped

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>
This commit is contained in:
2026-10-08 16:54:44 -04:00
co-authored by Claude Opus 5.5
parent ac38a04642
commit 2b6e12859a
18 changed files with 874 additions and 4 deletions
+4
View File
@@ -128,6 +128,10 @@ ACTIONS = {
"user.disabled": "disabled user",
"user.enabled": "enabled user",
"user.deleted": "deleted user",
"user.invited": "invited",
"user.invite_resent": "resent the invite to",
"user.invite_revoked": "withdrew the invite to",
"user.joined": "joined from an invite as",
"host.created": "added host",
"host.renamed": "renamed host",
"host.deleted": "deleted host",
+111
View File
@@ -1,4 +1,6 @@
from __future__ import annotations
from datetime import datetime, timezone
import bcrypt
from quart import Blueprint, render_template, request, redirect, url_for, session
from sqlalchemy import select, func
@@ -291,3 +293,112 @@ async def setup_post():
await log_audit(current_app, user.id, user.username, "auth.setup",
detail={"username": username})
return redirect(url_for("dashboard.index"))
# ── Invites (milestone 496) ──────────────────────────────────────────────────
_INVITE_GONE = ("This invite link has expired, been used or been withdrawn. "
"Ask whoever sent it for a new one.")
async def _invite_page(key: str, invite=None, inviter: str = "", error: str | None = None,
form=None, status: int = 200):
"""The invite page: the form while `invite` works, else one plain message
that doesn't say whether the key was unknown, used, revoked or expired."""
if invite is None and error is None:
error = _INVITE_GONE
return await render_template(
"auth/invite.html", key=key, invite=invite, inviter=inviter, error=error,
form=form or {}, min_password=_min_password()), status
def _min_password() -> int:
from steward.settings.users import MIN_PASSWORD
return MIN_PASSWORD
async def _inviter_name(db, invite) -> str:
if invite.invited_by is None:
return "An admin"
who = await db.get(User, invite.invited_by)
return who.username if who else "An admin"
def _invite_throttled() -> str | None:
"""Why this address may not try an invite key now (too many bad ones), or None."""
from quart import current_app
wait_s = login_throttle.retry_after(
client_ip(), current_app.config.get("LOGIN_MAX_FAILURES", 5),
current_app.config.get("LOGIN_LOCKOUT_MINUTES", 15) * 60)
if not wait_s:
return None
minutes = max(1, round(wait_s / 60))
return f"Too many tries from this address. Try again in {minutes} minute{'s' if minutes != 1 else ''}."
@auth_bp.get("/invite/<key>")
@public_route("an invite link: the key is the credential")
async def invite(key: str):
from quart import current_app
from steward.core.invites import find_pending
if wait := _invite_throttled():
return await _invite_page(key, error=wait, status=429)
async with current_app.db_sessionmaker() as db:
found = await find_pending(db, key)
inviter = await _inviter_name(db, found) if found else ""
if found is None:
login_throttle.failed(client_ip())
return await _invite_page(key, status=404)
return await _invite_page(key, found, inviter, form={"email": found.email})
@auth_bp.post("/invite/<key>")
@public_route("an invite link: the key is the credential")
async def invite_accept(key: str):
"""Make the invitee's local account from their own username, email and
password, with the role the admin chose; use the key up and sign them in,
in one transaction, so a key makes one account."""
from quart import current_app
from sqlalchemy.exc import IntegrityError
from steward.core.audit import log_audit
from steward.core.invites import find_pending, signup_problem, taken
from steward.settings.users import _hash
if wait := _invite_throttled():
return await _invite_page(key, error=wait, status=429)
form = await request.form
username = form.get("username") or ""
email = (form.get("email") or "").strip()
password, confirm = form.get("password") or "", form.get("confirm") or ""
shown = {"username": username, "email": email}
try:
async with current_app.db_sessionmaker() as db:
async with db.begin():
found = await find_pending(db, key, lock=True)
if found is None:
login_throttle.failed(client_ip())
return await _invite_page(key, status=404)
inviter = await _inviter_name(db, found)
problem = signup_problem(username, email, password, confirm) or await taken(db, username, email)
if problem:
return await _invite_page(key, found, inviter, problem, shown, 400)
user = User(username=username, email=email, role=found.role, auth_source="local",
password_hash=_hash(password))
db.add(user)
await db.flush()
found.used_at = datetime.now(timezone.utc)
found.used_by = user.id
invite_id, role = found.id, found.role.value
db.expunge(user)
except IntegrityError:
async with current_app.db_sessionmaker() as db:
found = await find_pending(db, key)
inviter = await _inviter_name(db, found) if found else ""
if found is None:
return await _invite_page(key, status=404)
return await _invite_page(key, found, inviter,
"That username or email was just taken. Pick another.", shown, 400)
login_user(user)
await log_audit(current_app, user.id, user.username, "user.joined", "user", user.id,
detail={"username": user.username, "role": role, "invite": invite_id,
"invited_by": inviter})
return redirect(url_for("dashboard.index"))
+114
View File
@@ -0,0 +1,114 @@
"""Invite a user by email (milestone 496).
An admin invites an address with a role and a number of days. The invitee
gets a link carrying a one-time key and makes their own local account from
it: username, email and password. Only the key's sha256 is kept, so a
database read can't produce a working link. A key works once, until it
expires or is revoked; resending replaces it, so an older link stops
working.
"""
from __future__ import annotations
import secrets
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from steward.core.api_tokens import hash_token
from steward.models.invites import UserInvite
from steward.models.users import User, UserRole
DAYS = (1, 7, 30)
DEFAULT_DAYS = 7
MAX_USERNAME = 64
def new_key() -> str:
return secrets.token_urlsafe(32)
def state(invite: UserInvite, now: datetime | None = None) -> str:
""""used", "revoked", "expired" or "pending"."""
now = now or datetime.now(timezone.utc)
if invite.used_at is not None:
return "used"
if invite.revoked_at is not None:
return "revoked"
if invite.expires_at <= now:
return "expired"
return "pending"
def link(base: str, key: str) -> str:
return f"{base.rstrip('/')}/invite/{key}"
def _issue(invite: UserInvite, now: datetime) -> str:
"""Give `invite` a fresh key and a fresh clock; returns the raw key."""
key = new_key()
invite.key_hash = hash_token(key)
invite.sent_at = now
invite.expires_at = now + timedelta(days=invite.days)
return key
async def create(db, email: str, role: UserRole, days: int, invited_by: str | None) -> tuple[UserInvite, str]:
"""A new invite, flushed so it has an id, and its raw key (shown or sent
once, never stored). An open invite to the same address is revoked, so
an address holds one working link at a time. Call in a transaction."""
now = datetime.now(timezone.utc)
for old in (await db.execute(select(UserInvite).where(
UserInvite.email == email, UserInvite.used_at.is_(None),
UserInvite.revoked_at.is_(None)))).scalars():
old.revoked_at = now
invite = UserInvite(email=email, role=role, days=days, invited_by=invited_by, created_at=now)
key = _issue(invite, now)
db.add(invite)
await db.flush()
return invite, key
def renew(invite: UserInvite) -> str:
"""Resend: a new key and a new expiry, so the old link stops working."""
return _issue(invite, datetime.now(timezone.utc))
async def find_pending(db, key: str, lock: bool = False) -> UserInvite | None:
"""The invite `key` opens, while it still works; None for any other key."""
if not key or len(key) > 128:
return None
query = select(UserInvite).where(UserInvite.key_hash == hash_token(key))
if lock:
query = query.with_for_update()
invite = (await db.execute(query)).scalar_one_or_none()
return invite if invite is not None and state(invite) == "pending" else None
async def open_invites(db) -> list[UserInvite]:
"""Invites not yet used or revoked, newest first; expired ones included,
so they can be resent."""
return list((await db.execute(select(UserInvite).where(
UserInvite.used_at.is_(None), UserInvite.revoked_at.is_(None))
.order_by(UserInvite.sent_at.desc()))).scalars())
def signup_problem(username: str, email: str, password: str, confirm: str) -> str | None:
"""Why the invitee's form can't make an account, or None."""
from steward.core.mail import is_real_address
from steward.settings.users import password_problem
if not username or len(username) > MAX_USERNAME or username != username.strip():
return f"Pick a username of up to {MAX_USERNAME} characters, without spaces at either end."
if not is_real_address(email) or len(email) > 255:
return "Enter an email address mail can reach."
return password_problem(password, confirm)
async def taken(db, username: str, email: str) -> str | None:
"""Why the username or email is already in use, or None."""
rows = (await db.execute(select(User.username, User.email).where(
(User.username == username) | (User.email == email)))).all()
if any(u == username for u, _ in rows):
return f"The username {username} is taken. Pick another."
if rows:
return "Another account already uses that email address."
return None
+19
View File
@@ -262,3 +262,22 @@ def report_email(cfg: dict, data: dict, to: Recipient) -> tuple[str, str, str]:
text, html = _render("report", d=data, alerts=alerts, period=period,
sent=moment(data["generated_at"], tz), base=base)
return f"[Steward] Weekly report, {period}", text, html
ROLE_WORDS = {
"viewer": "a viewer: you can read every page",
"operator": "an operator: you can also run playbooks and change monitors, hosts and alerts",
"admin": "an admin: you can also change Settings and users",
}
def invite_email(inviter: str, role: str, expires_at: datetime, url: str,
tz: tzinfo) -> tuple[str, str, str]:
"""(subject, text, html) for an invite (milestone 496): who sent it, the
role it gives, when the link stops working (in the sender's zone, with
UTC beside it, since the invitee's zone isn't known) and the link."""
local, utc = moment(expires_at, tz), moment(expires_at, timezone.utc)
facts = {"inviter": inviter, "role": ROLE_WORDS.get(role, role), "link": url,
"expires": local, "expires_utc": utc if utc != local else ""}
text, html = _render("invite", i=facts)
return f"[Steward] {inviter} invited you to Steward", text, html
@@ -0,0 +1,40 @@
"""Emailed invitations to make a local account (milestone 496): a one-time
key, stored as its sha256, with the role and expiry the admin chose.
Revision ID: 0037_user_invites
Revises: 0036_user_email_choices
Create Date: 2026-10-08
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "0037_user_invites"
down_revision: Union[str, None] = "0036_user_email_choices"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"user_invites",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("key_hash", sa.String(64), nullable=False, unique=True),
sa.Column("email", sa.String(255), nullable=False),
# The users table's enum type, already created.
sa.Column("role", postgresql.ENUM("admin", "operator", "viewer", name="userrole", create_type=False),
nullable=False),
sa.Column("days", sa.Integer, nullable=False),
sa.Column("invited_by", sa.String(36), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("sent_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("used_by", sa.String(36), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_table("user_invites")
+2 -1
View File
@@ -1,6 +1,7 @@
from .base import Base
from .users import User
from .api_tokens import ApiToken
from .invites import UserInvite
from .hosts import Host
from .monitors import Monitor, MonitorResult, MonitorType
from .metrics import PluginMetric
@@ -12,7 +13,7 @@ from .settings import AppSetting
from .dashboard import Dashboard, DashboardWidget, DashboardShareToken
__all__ = [
"Base", "User", "ApiToken",
"Base", "User", "ApiToken", "UserInvite",
"Host",
"Monitor", "MonitorResult", "MonitorType",
"PluginMetric",
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
from .users import UserRole
class UserInvite(Base):
"""An emailed invitation to make a local account (milestone 496). The link
carries a one-time key; only its sha256 is stored, as for API tokens. The
admin chose the role and how many days the link works; the invitee
chooses their own username, email and password."""
__tablename__ = "user_invites"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
key_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
# Where the invite was sent; the invitee may sign up with another address.
email: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[UserRole] = mapped_column(Enum(UserRole), nullable=False, default=UserRole.viewer)
days: Mapped[int] = mapped_column(Integer, nullable=False, default=7)
invited_by: Mapped[str | None] = mapped_column(
String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
# When the current key was sent; a resend replaces the key and restarts the clock.
sent_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
used_by: Mapped[str | None] = mapped_column(
String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+114 -2
View File
@@ -16,13 +16,14 @@ from sqlalchemy import func, or_, select
from sqlalchemy.exc import IntegrityError
from steward.auth.middleware import require_role
from steward.core import mail
from steward.core import invites, mail
from steward.core.audit import audit_request
from steward.core.settings import get_all_settings
from steward.models.alerts import AlertRule, MaintenanceWindow
from steward.models.ansible import AnsibleRun
from steward.models.api_tokens import ApiToken
from steward.models.audit import AuditEvent
from steward.models.invites import UserInvite
from steward.models.users import User, UserRole
users_bp = Blueprint("users", __name__, url_prefix="/settings/users")
@@ -105,14 +106,22 @@ async def users_list():
last = await _last_sign_ins(db)
tokens = await _token_counts(db)
settings = await get_all_settings(db)
pending = await invites.open_invites(db)
users = sorted(users, key=lambda u: (not u.is_active, u.username.lower()))
names = {u.id: u.username for u in users}
gets = {u.id: [label for kind, label in (("alerts", "alerts"), ("report", "report"))
if u.is_active and mail.wants(u, kind, settings)] for u in users}
return await render_template(
"settings/users.html", users=users, last=last, tokens=tokens, sources=SOURCES,
gets=gets, reachable={u.id for u in users if mail.is_real_address(u.email)},
admins=sum(1 for u in users if u.role == UserRole.admin and u.is_active),
disabled=sum(1 for u in users if not u.is_active))
disabled=sum(1 for u in users if not u.is_active),
invites=[(i, invites.state(i), names.get(i.invited_by, "—")) for i in pending],
done=_LIST_DONE.get(request.args.get("done", "")))
_LIST_DONE = {"invited": "Invite sent.", "resent": "Invite sent again; the earlier link no longer works.",
"revoked": "Invite withdrawn; its link no longer works."}
async def _new_page(error: str | None = None, form=None, status: int = 200):
@@ -155,6 +164,109 @@ async def user_create():
return redirect(f"/settings/users/{user.id}?done=created")
# ── Invites (milestone 496) ──────────────────────────────────────────────────
async def _invite_form(error: str | None = None, form=None, status: int = 200):
return await render_template("settings/user_invite.html", error=error, form=form or {},
roles=list(UserRole), days=invites.DAYS,
default_days=invites.DEFAULT_DAYS,
mail_ready=bool(current_app.config.get("SMTP", {}).get("host"))), status
async def _deliver(invite: UserInvite, key: str) -> str | None:
"""Email the invite's link to its address, from the signed-in admin. None
when it was sent; otherwise why not, and the caller shows the link."""
from steward.core.formatting import zone
from steward.core.settings import public_base_url
cfg = current_app.config.get("SMTP", {})
if not cfg.get("host"):
return "No mail server is set (Settings › Notifications), so nothing was sent."
url = invites.link(public_base_url(request), key)
subject, text, html = mail.invite_email(session.get("username", "An admin"), invite.role.value,
invite.expires_at, url, zone(session.get("tz")))
try:
await mail.send(cfg, [mail.message(cfg, invite.email, subject, text, html)])
except Exception as exc:
current_app.logger.warning("Invite email to %s failed: %s", invite.email, exc)
return f"Sending to {invite.email} failed ({exc})."
return None
async def _link_page(invite: UserInvite, key: str, why: str):
"""The link shown once, to pass on by hand, when mail didn't take it."""
from steward.core.settings import public_base_url
return await render_template("settings/user_invite_link.html", invite=invite, why=why,
url=invites.link(public_base_url(request), key))
@users_bp.get("/invite")
@require_role(UserRole.admin)
async def user_invite():
return await _invite_form()
@users_bp.post("/invite")
@require_role(UserRole.admin)
async def user_invite_send():
form = await request.form
email = (form.get("email") or "").strip()
try:
role = UserRole(form.get("role") or "viewer")
days = int(form.get("days") or invites.DEFAULT_DAYS)
except ValueError:
return await _invite_form("Pick a role and how long the link works.", form, 400)
if days not in invites.DAYS:
return await _invite_form("Pick how long the link works.", form, 400)
if not mail.is_real_address(email) or len(email) > 255:
return await _invite_form("Enter an email address mail can reach.", form, 400)
async with current_app.db_sessionmaker() as db:
async with db.begin():
holder = (await db.execute(select(User.username).where(User.email == email))).scalar()
if holder is None:
invite, key = await invites.create(db, email, role, days, session.get("user_id"))
if holder is not None:
return await _invite_form(f"{holder} already has an account with that address.", form, 400)
await audit_request("user.invited", "invite", str(invite.id),
{"name": email, "role": role.value, "days": days})
why = await _deliver(invite, key)
if why:
return await _link_page(invite, key, why)
return redirect("/settings/users/?done=invited")
async def _open_invite(db, invite_id: int) -> UserInvite:
invite = await db.get(UserInvite, invite_id)
if invite is None or invites.state(invite) in ("used", "revoked"):
abort(404)
return invite
@users_bp.post("/invites/<int:invite_id>/resend")
@require_role(UserRole.admin)
async def user_invite_resend(invite_id: int):
"""A new link with a new expiry; the old link stops working."""
async with current_app.db_sessionmaker() as db:
async with db.begin():
invite = await _open_invite(db, invite_id)
key = invites.renew(invite)
await audit_request("user.invite_resent", "invite", str(invite.id), {"name": invite.email})
why = await _deliver(invite, key)
if why:
return await _link_page(invite, key, why)
return redirect("/settings/users/?done=resent")
@users_bp.post("/invites/<int:invite_id>/revoke")
@require_role(UserRole.admin)
async def user_invite_revoke(invite_id: int):
async with current_app.db_sessionmaker() as db:
async with db.begin():
invite = await _open_invite(db, invite_id)
invite.revoked_at = datetime.now(timezone.utc)
await audit_request("user.invite_revoked", "invite", str(invite.id), {"name": invite.email})
return redirect("/settings/users/?done=revoked")
_DONE = {"created": "Account added.", "saved": "Saved.", "role": "Role changed.",
"password": "Password set. It works from their next sign-in.",
"disabled": "Account disabled. They are signed out at their next click.",
+8
View File
@@ -451,6 +451,14 @@ a.stat-tile:hover { border-color: var(--border-mid); background: var(--bg-elevat
.form-section > .section-title { margin-bottom: 0.9rem; }
.form-pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(14rem, 100%), 1fr)); gap: 0 1rem; }
.form-pair[hidden] { display: none; }
/* The invite page (milestone 496): the login page's centred card, with labelled fields. */
.invite-page { max-width: 420px; margin: 4rem auto; }
.invite-title { font-family: var(--font-serif); font-size: 1.5rem; font-weight: 500; margin: 0 0 0.5rem; }
.invite-lede { color: var(--text-muted); margin: 0 0 1.25rem; }
.invite-submit { width: 100%; margin-top: 0.25rem; }
/* A link shown once to copy (an invite without mail): the value and its Copy button. */
.copy-once { display: flex; gap: 0.5rem; align-items: center; }
.copy-once .input { flex: 1; font-family: ui-monospace, monospace; font-size: 0.8rem; }
/* A column of checkboxes: a target's groups, a group's members. */
.check-list { display: grid; gap: 0.4rem; }
.ansible-fields { padding: 0.9rem 1rem 0.25rem; margin-bottom: 0.75rem; border: 1px solid var(--border); border-radius: var(--fs-radius-md); background: var(--bg-elevated); }
+43
View File
@@ -0,0 +1,43 @@
{# auth/invite.html — where an invitee makes their own account (milestone 496).
`invite` is the working invite, or none: then only `error` shows. #}
{% extends "base.html" %}
{% block title %}Join Steward{% endblock %}
{% block content %}
<div class="invite-page">
<div class="card">
<h1 class="invite-title">Join Steward</h1>
{% if invite %}
<p class="invite-lede">{{ inviter }} invited you as {{ "an" if invite.role.value in ("admin", "operator") else "a" }} {{ invite.role.value }}. Choose your username, email and password.</p>
{% if session.user_id %}<p class="field-hint">You're signed in as {{ session.username }}. Making this account signs you in as it instead.</p>{% endif %}
{% endif %}
{% if error %}<div class="alert alert-error" role="alert">{{ error }}</div>{% endif %}
{% if invite %}
<form method="post" action="/invite/{{ key }}">{{ csrf_field() }}
<div class="field">
<label class="field-label" for="inv-username">Username</label>
<input class="input" id="inv-username" type="text" name="username" maxlength="64" required autofocus
autocomplete="username" value="{{ form.get('username', '') }}">
</div>
<div class="field">
<label class="field-label" for="inv-email">Email</label>
<input class="input" id="inv-email" type="email" name="email" maxlength="255" required
autocomplete="email" value="{{ form.get('email', '') }}">
<span class="field-hint">Steward sends alerts and the weekly report here; you can turn them off later.</span>
</div>
<div class="field">
<label class="field-label" for="inv-pw">Password</label>
<input class="input" id="inv-pw" type="password" name="password" minlength="{{ min_password }}" required autocomplete="new-password">
<span class="field-hint">At least {{ min_password }} characters.</span>
</div>
<div class="field">
<label class="field-label" for="inv-pw2">Password again</label>
<input class="input" id="inv-pw2" type="password" name="confirm" required autocomplete="new-password">
</div>
<button type="submit" class="btn invite-submit">Make my account</button>
</form>
{% else %}
<a href="/login" class="btn btn-ghost">Go to sign in</a>
{% endif %}
</div>
</div>
{% endblock %}
+19
View File
@@ -0,0 +1,19 @@
{# An invite email's HTML part (milestone 496): i = the facts mail.invite_email builds. #}
{% extends "_layout.html" %}
{% from "_parts.html" import button, fact with context %}
{% block title %}{{ i.inviter }} invited you to Steward{% endblock %}
{% block body %}
<tr><td style="padding:10px 24px 4px 24px;font:600 20px/1.35 {{ c.font }};color:{{ c.text }};">{{ i.inviter }} invited you to Steward.</td></tr>
<tr><td style="padding:6px 24px 4px 24px;font:14px/1.55 {{ c.font }};color:{{ c.text }};">Open the link to make your account: you choose your username, email and password.</td></tr>
<tr><td style="padding:10px 24px 4px 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
{{ fact("You'll be", i.role) }}
{% call fact("Link works until") %}{{ i.expires }}{% if i.expires_utc %}<br><span style="color:{{ c.muted }};">{{ i.expires_utc }}</span>{% endif %}{% endcall %}
</table>
</td></tr>
<tr><td style="padding:16px 24px 8px 24px;">{{ button(i.link, "Make your account") }}</td></tr>
<tr><td style="padding:4px 24px 20px 24px;font:12px/1.5 {{ c.font }};color:{{ c.muted }};word-break:break-all;">Or paste this into your browser: {{ i.link }}</td></tr>
{% endblock %}
{% block footer %}
The link works once. If you weren't expecting this, ignore it and nothing happens.
{% endblock %}
+14
View File
@@ -0,0 +1,14 @@
{# An invite email's plain-text part; the same facts as invite.html. #}
{{ i.inviter }} invited you to Steward.
Open the link to make your account: you choose your username, email and
password.
You'll be {{ i.role }}.
Link works until: {{ i.expires }}{% if i.expires_utc %} ({{ i.expires_utc }}){% endif %}
Make your account: {{ i.link }}
--
The link works once. If you weren't expecting this, ignore it and nothing
happens.
@@ -0,0 +1,43 @@
{# settings/user_invite.html — invite someone by email (milestone 496): they
make their own account from the link, with the role chosen here. #}
{% extends "base.html" %}
{% from "_macros.html" import crumbs, page_header %}
{% from "settings/_tabs.html" import settings_tabs %}
{% block title %}Invite by email — Settings — Steward{% endblock %}
{% block section_tabs %}{{ settings_tabs("users") }}{% endblock %}
{% block breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("Users", "/settings/users/"), ("Invite by email", "")]) }}{% endblock %}
{% block content %}
{{ page_header("Invite by email", "They choose their own username, email and password from the link.") }}
{% if error %}<div class="alert alert-error" role="alert">{{ error }}</div>{% endif %}
<form method="post" action="/settings/users/invite" class="form-page">{{ csrf_field() }}
<section class="card form-section" aria-labelledby="inv-who">
<h2 id="inv-who" class="section-title">Invite</h2>
<div class="field">
<label class="field-label" for="inv-email">Send to</label>
<input class="input" id="inv-email" type="email" name="email" maxlength="255" required autofocus
value="{{ form.get('email', '') }}" autocomplete="off">
{% if not mail_ready %}<span class="field-hint tone-warn">No mail server is set, so you'll get the link to pass on yourself. Set one in Settings › <a href="/settings/notifications/">Notifications</a>.</span>{% endif %}
</div>
<div class="form-pair">
<div class="field">
<label class="field-label" for="inv-role">Role</label>
<select class="input" id="inv-role" name="role">
{% for r in roles %}<option value="{{ r.value }}" {% if form.get('role', 'viewer') == r.value %}selected{% endif %}>{{ r.value|capitalize }}</option>{% endfor %}
</select>
</div>
<div class="field">
<label class="field-label" for="inv-days">Link works for</label>
<select class="input" id="inv-days" name="days">
{% for d in days %}<option value="{{ d }}" {% if form.get('days', default_days|string) == d|string %}selected{% endif %}>{{ d }} day{{ 's' if d != 1 }}</option>{% endfor %}
</select>
</div>
</div>
<p class="field-hint">A viewer reads; an operator also runs playbooks and changes monitors, hosts and alerts; an admin also changes Settings and users. They can't change the role you pick. The link works once.</p>
</section>
<div class="form-actions">
<button type="submit" class="btn">{{ "Send invite" if mail_ready else "Make invite link" }}</button>
<a href="/settings/users/" class="btn btn-ghost">Cancel</a>
</div>
</form>
{% endblock %}
@@ -0,0 +1,24 @@
{# settings/user_invite_link.html — an invite's link, shown once (milestone
496), when mail didn't carry it: no mail server, or sending failed. #}
{% extends "base.html" %}
{% from "_macros.html" import crumbs, page_header %}
{% from "settings/_tabs.html" import settings_tabs %}
{% block title %}Invite link — Settings — Steward{% endblock %}
{% block section_tabs %}{{ settings_tabs("users") }}{% endblock %}
{% block breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("Users", "/settings/users/"), ("Invite link", "")]) }}{% endblock %}
{% block content %}
{{ page_header("Invite link for " ~ invite.email) }}
<div class="form-page">
<p class="page-verdict" role="status">{{ why }}</p>
<section class="card form-section" aria-labelledby="link-title">
<h2 id="link-title" class="section-title">Copy this link now. It can't be shown again.</h2>
<div class="copy-once">
<input class="input" type="text" id="invite-url" value="{{ url }}" readonly aria-label="Invite link">
<button type="button" class="btn btn-ghost btn-sm"
onclick="navigator.clipboard.writeText(document.getElementById('invite-url').value).then(()=>this.textContent='Copied')">Copy</button>
</div>
<p class="field-hint">Send it to {{ invite.email }} yourself. It makes one {{ invite.role.value }} account and works until {{ invite.expires_at|when }}. If you lose it, resend the invite from Users for a new link.</p>
</section>
<div class="form-actions"><a href="/settings/users/" class="btn">Back to Users</a></div>
</div>
{% endblock %}
+26 -1
View File
@@ -7,7 +7,9 @@
{% block section_tabs %}{{ settings_tabs("users") }}{% endblock %}
{% block breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("Users", "")]) }}{% endblock %}
{% block content %}
{% call page_header("Users") %}<a href="/settings/users/new" class="btn">Add user</a>{% endcall %}
{% call page_header("Users") %}<a href="/settings/users/invite" class="btn">Invite by email</a><a href="/settings/users/new" class="btn btn-ghost">Add user</a>{% endcall %}
{% if done %}<div class="alert alert-success" role="status">{{ done }}</div>{% endif %}
<p class="page-verdict">
{{ users|length }} account{{ 's' if users|length != 1 }}
@@ -37,5 +39,28 @@
</tbody>
</table>
</div>
{% if invites %}
<div class="card-flush">
<table class="table">
<caption class="table-caption">Invites not yet used</caption>
<thead><tr><th>Sent to</th><th>Role</th><th>Sent by</th><th>Link works until</th><th class="num">Actions</th></tr></thead>
<tbody>
{% for i, state, by in invites %}
<tr>
<td>{{ i.email }}</td>
<td>{{ i.role.value|capitalize }}</td>
<td>{{ by }}</td>
<td class="nowrap">{% if state == "expired" %}{{ status_badge("inactive", "Expired") }}{% else %}{{ i.expires_at|when }}{% endif %}</td>
<td class="num nowrap">
<form method="post" action="/settings/users/invites/{{ i.id }}/resend" class="inline-form">{{ csrf_field() }}<button type="submit" class="btn btn-ghost btn-sm">Resend</button></form>
<form method="post" action="/settings/users/invites/{{ i.id }}/revoke" class="inline-form"
data-confirm="Withdraw the invite to {{ i.email }}? Its link stops working.">{{ csrf_field() }}<button type="submit" class="btn btn-ghost btn-sm">Withdraw</button></form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<p class="field-hint">Accounts that sign in with LDAP or single sign-on are added at their first sign-in, with the role their provider's groups give them (Settings › <a href="/settings/auth/">Authentication</a>).</p>
{% endblock %}
+64
View File
@@ -0,0 +1,64 @@
"""Invite a user by email (milestone 496): an invite's states, what the
invitee's form must hold, and the email that carries the link."""
import re
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
from steward.core import invites, mail
from steward.core.api_tokens import hash_token
from steward.models.invites import UserInvite
from steward.models.users import UserRole
NOW = datetime(2026, 10, 8, 20, 0, tzinfo=timezone.utc)
def _invite(**kw):
base = dict(email="new@example.com", role=UserRole.viewer, days=7, key_hash="x",
created_at=NOW, sent_at=NOW, expires_at=NOW + timedelta(days=7))
return UserInvite(**(base | kw))
def test_an_invite_is_pending_until_used_revoked_or_past_its_day():
assert invites.state(_invite(), NOW) == "pending"
assert invites.state(_invite(), NOW + timedelta(days=7)) == "expired"
assert invites.state(_invite(used_at=NOW), NOW) == "used"
assert invites.state(_invite(revoked_at=NOW), NOW) == "revoked"
# Used wins over expired: the account was made in time.
assert invites.state(_invite(used_at=NOW), NOW + timedelta(days=30)) == "used"
def test_a_resend_replaces_the_key_and_restarts_the_clock():
invite = _invite(days=1, sent_at=NOW - timedelta(days=3), expires_at=NOW - timedelta(days=2))
key = invites.renew(invite)
assert invite.key_hash == hash_token(key) and len(key) >= 40
assert invites.state(invite) == "pending"
assert invite.expires_at - invite.sent_at == timedelta(days=1)
assert invites.renew(invite) != key
def test_the_link_carries_the_key_under_steward_s_address():
assert invites.link("https://steward.example/", "abc") == "https://steward.example/invite/abc"
def test_the_invitee_s_form_needs_a_name_a_real_address_and_a_matching_password():
ok = ("ann", "ann@example.com", "longenough", "longenough")
assert invites.signup_problem(*ok) is None
for bad in (("", *ok[1:]), (" ann", *ok[1:]), ("a" * 65, *ok[1:]),
("ann", "ann@oidc", *ok[2:]), ("ann", "ann@example.com", "short", "short"),
("ann", "ann@example.com", "longenough", "different1")):
assert invites.signup_problem(*bad), bad
def test_the_invite_email_says_who_what_until_when_and_carries_the_link():
url = "https://steward.example/invite/KEY123"
subject, text, html = mail.invite_email("bvan <b>", "operator", NOW + timedelta(days=7), url,
ZoneInfo("America/New_York"))
assert subject == "[Steward] bvan <b> invited you to Steward"
for part in (text, html):
assert url in part
assert "an operator: you can also run playbooks" in part
assert "Thu 15 Oct 2026 16:00 EDT" in part and "Thu 15 Oct 2026 20:00 UTC" in part
assert "bvan &lt;b&gt;" in html and "<b>" not in html
# Nothing in the HTML reaches anywhere but Steward, or loads at all.
assert all(u.startswith("https://steward.example") for u in re.findall(r"""https?://[^\s"'<>)]+""", html))
assert not re.search(r"""\ssrc\s*=|url\(|<link\b|@import""", html, re.I)
+195
View File
@@ -0,0 +1,195 @@
"""Integration: invite a user by email (milestone 496), from the Users page
to the invitee's own account. The routes run for real against Postgres;
only mail delivery is replaced."""
from __future__ import annotations
import asyncio
import json
import os
import re
import time
import uuid
import pytest
pytestmark = pytest.mark.integration
CSRF = {"X-CSRF-Token": "test-token"}
_KEY = re.compile(r"/invite/([A-Za-z0-9_-]{20,})")
@pytest.fixture
def app():
if not os.environ.get("STEWARD_DATABASE_URL"):
pytest.skip("needs Postgres")
from steward.app import create_app
from steward.auth.throttle import login_throttle
login_throttle._failures.clear() # bad keys below count against this address
app = create_app(testing=False)
yield app
login_throttle._failures.clear()
async def _admin(app, role="admin"):
from steward.models.users import User, UserRole
uid = str(uuid.uuid4())
name = f"inv-{uid[:8]}"
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(User(id=uid, username=name, email=f"{uid}@test", password_hash="x",
role=UserRole(role), is_active=True, time_zone="America/New_York"))
client = app.test_client()
async with client.session_transaction() as sess:
sess.update(user_id=uid, user_role=role, username=name, _csrf="test-token", login_at=time.time())
return client, uid, name
async def _stranger(app):
"""A browser with no session, holding only the link."""
client = app.test_client()
await client.get("/login") # a session with a CSRF token, as a real visit has
async with client.session_transaction() as sess:
sess["_csrf"] = "test-token"
return client
async def _audits(app, uid):
from sqlalchemy import select
from steward.models.audit import AuditEvent
async with app.db_sessionmaker() as s:
rows = (await s.execute(select(AuditEvent).where(AuditEvent.user_id == uid)
.order_by(AuditEvent.timestamp))).scalars().all()
return [(r.action, json.loads(r.detail_json or "{}")) for r in rows]
def _mail(app, monkeypatch):
from steward.core import mail
sent = []
async def fake_send(cfg, messages):
sent.extend(messages)
app.config["SMTP"] = {"host": "mx.example", "port": 587, "from": "steward@example.com"}
monkeypatch.setattr(mail, "send", fake_send)
return sent
def test_an_invite_is_emailed_and_the_invitee_makes_their_own_account_once(app, monkeypatch):
sent = _mail(app, monkeypatch)
async def _go():
from steward.models.users import User
admin, aid, aname = await _admin(app)
page = await (await admin.get("/settings/users/")).get_data(as_text=True)
assert 'href="/settings/users/invite"' in page # findable from the list
to = f"new-{aid[:8]}@example.com"
resp = await admin.post("/settings/users/invite", headers=CSRF,
form={"email": to, "role": "operator", "days": "1"})
assert resp.status_code == 302 and resp.headers["Location"].endswith("?done=invited")
assert len(sent) == 1 and sent[0]["To"] == to
text = sent[0].get_body(("plain",)).get_content()
assert f"{aname} invited you to Steward" in text and "EDT" in text and "UTC" in text
key = _KEY.search(text).group(1)
page = await (await admin.get(resp.headers["Location"])).get_data(as_text=True)
assert "Invites not yet used" in page and to in page and "Invite sent." in page
assert [a for a, _ in await _audits(app, aid)] == ["user.invited"]
guest = await _stranger(app)
page = await (await guest.get(f"/invite/{key}")).get_data(as_text=True)
assert "Join Steward" in page and f"{aname} invited you as an operator" in page
assert f'value="{to}"' in page # prefilled, and theirs to change
mine = f"me-{aid[:8]}@example.com"
form = {"username": f"joined-{aid[:8]}", "email": mine, "password": "longenough", "confirm": "nope"}
resp = await guest.post(f"/invite/{key}", headers=CSRF, form=form)
assert resp.status_code == 400 and "don't match" in await resp.get_data(as_text=True)
resp = await guest.post(f"/invite/{key}", headers=CSRF, form=form | {"username": aname, "confirm": "longenough"})
assert resp.status_code == 400 and f"username {aname} is taken" in await resp.get_data(as_text=True)
resp = await guest.post(f"/invite/{key}", headers=CSRF, form=form | {"confirm": "longenough"})
assert resp.status_code == 302
async with guest.session_transaction() as sess:
new_id = sess["user_id"]
async with app.db_sessionmaker() as s:
user = await s.get(User, new_id)
assert (user.username, user.email, user.role.value, user.auth_source) == (
f"joined-{aid[:8]}", mine, "operator", "local")
assert [a for a, _ in await _audits(app, new_id)] == ["user.joined"]
# The key made one account; it opens nothing now.
again = await _stranger(app)
resp = await again.get(f"/invite/{key}")
assert resp.status_code == 404 and "expired, been used or been withdrawn" in await resp.get_data(as_text=True)
resp = await again.post(f"/invite/{key}", headers=CSRF,
form=form | {"username": "second", "email": "second@example.com", "confirm": "longenough"})
assert resp.status_code == 404
page = await (await admin.get("/settings/users/")).get_data(as_text=True)
assert to not in page # a used invite leaves the list
asyncio.run(_go())
def test_without_mail_the_admin_gets_the_link_once(app):
async def _go():
app.config["SMTP"] = {}
admin, aid, _ = await _admin(app)
page = await (await admin.get("/settings/users/invite")).get_data(as_text=True)
assert "No mail server is set" in page and "Make invite link" in page
resp = await admin.post("/settings/users/invite", headers=CSRF,
form={"email": f"nomail-{aid[:8]}@example.com", "role": "viewer", "days": "7"})
page = await resp.get_data(as_text=True)
assert resp.status_code == 200 and "It can't be shown again" in page
key = _KEY.search(page).group(1)
assert (await (await _stranger(app)).get(f"/invite/{key}")).status_code == 200
asyncio.run(_go())
def test_resend_replaces_the_link_and_withdraw_ends_it(app, monkeypatch):
sent = _mail(app, monkeypatch)
async def _go():
from sqlalchemy import select
from steward.models.invites import UserInvite
admin, aid, _ = await _admin(app)
to = f"again-{aid[:8]}@example.com"
await admin.post("/settings/users/invite", headers=CSRF, form={"email": to, "role": "viewer", "days": "7"})
first = _KEY.search(sent[-1].get_body(("plain",)).get_content()).group(1)
async with app.db_sessionmaker() as s:
invite_id = (await s.execute(select(UserInvite.id).where(UserInvite.email == to))).scalar_one()
resp = await admin.post(f"/settings/users/invites/{invite_id}/resend", headers=CSRF)
assert resp.status_code == 302 and len(sent) == 2
second = _KEY.search(sent[-1].get_body(("plain",)).get_content()).group(1)
guest = await _stranger(app)
assert (await guest.get(f"/invite/{first}")).status_code == 404
assert (await guest.get(f"/invite/{second}")).status_code == 200
resp = await admin.post(f"/settings/users/invites/{invite_id}/revoke", headers=CSRF)
assert resp.status_code == 302
assert (await guest.get(f"/invite/{second}")).status_code == 404
assert (await admin.post(f"/settings/users/invites/{invite_id}/resend", headers=CSRF)).status_code == 404
assert [a for a, _ in await _audits(app, aid)] == ["user.invited", "user.invite_resent", "user.invite_revoked"]
asyncio.run(_go())
def test_an_address_with_an_account_is_not_invited_and_only_an_admin_invites(app, monkeypatch):
_mail(app, monkeypatch)
async def _go():
admin, aid, _ = await _admin(app)
resp = await admin.post("/settings/users/invite", headers=CSRF,
form={"email": f"{aid}@test", "role": "viewer", "days": "7"})
assert resp.status_code == 400 and "already has an account" in await resp.get_data(as_text=True)
resp = await admin.post("/settings/users/invite", headers=CSRF,
form={"email": "x@example.com", "role": "viewer", "days": "3"})
assert resp.status_code == 400
operator, _, _ = await _admin(app, role="operator")
assert (await operator.get("/settings/users/invite")).status_code == 403
assert (await operator.post("/settings/users/invite", headers=CSRF,
form={"email": "y@example.com"})).status_code == 403
asyncio.run(_go())
+1
View File
@@ -26,6 +26,7 @@ TOP_LEVEL = {
# Logged out, or no page to go back up from.
"steward/templates/auth/login.html",
"steward/templates/auth/setup.html",
"steward/templates/auth/invite.html",
"steward/templates/errors/404.html",
"steward/templates/errors/500.html",
"steward/templates/errors/database_unavailable.html",