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
+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",