feat(users): invite by email — a one-time link where the invitee makes their own account (milestone 496)
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:
@@ -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 <b>" 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)
|
||||
@@ -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())
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user