#5508 "Every other email through the outbox: weekly report, invites, password resets, and the test buttons" (milestone 501). - outbox.send_each queues one email per reader (claimed, so the sender doesn't take it too) and tries them at once, for a page that answers with what happened; outbox.said words the result. attempt takes a cfg for a test of the form's unsaved settings and returns the settled rows. - Weekly report (scheduled and Send now) and invites: queued, tried at once; one the server couldn't take yet is retried for an hour. An invite that didn't go shows its link with the reason, as before. - Password reset: queued in the reset link's own transaction; the sender delivers it on its next tick. - Settings -> Notifications test email and a rule's Send test: queued and recorded with retry=False, tried once (System 156 ruling). - Removed mail.send, notifications._send_email and _smtp_send_sync; dispatch_notifications is webhook only. A guard asserts only the outbox (and smtp_open itself) opens an SMTP connection, with a self-test. Also fixes run 8941 (#5507's integration test): its Also-send-to addresses were "@test", which is_real_address rightly drops; they are example.com now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
427 lines
19 KiB
Python
427 lines
19 KiB
Python
"""Settings › Users (#5336): who has an account, and an admin's changes to one.
|
||
|
||
A local account's role, name and password are set here. An LDAP or single
|
||
sign-on account's role comes from its provider's group mapping at every
|
||
sign-in, so it is shown, not edited. Nobody can disable, demote or delete
|
||
their own account, and the last active admin cannot be demoted or disabled,
|
||
so Steward always keeps someone who can get back in. Every change is audited.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import time
|
||
from datetime import datetime, timezone
|
||
|
||
import bcrypt
|
||
from quart import Blueprint, abort, current_app, redirect, render_template, request, session
|
||
from sqlalchemy import func, or_, select
|
||
from sqlalchemy.exc import IntegrityError
|
||
|
||
from steward.auth.middleware import require_role
|
||
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")
|
||
|
||
SOURCES = {"local": "Steward password", "ldap": "LDAP", "oidc": "Single sign-on"}
|
||
MIN_PASSWORD = 8
|
||
|
||
|
||
def refusal(change: str, target: User, actor_id: str | None, active_admins: int) -> str | None:
|
||
"""Why `change` ("demote", "disable" or "delete") may not be made to
|
||
`target` by the signed-in admin, or None when it may."""
|
||
if target.id == actor_id:
|
||
return {"demote": "You can't lower your own role. Another admin can.",
|
||
"disable": "You can't disable your own account.",
|
||
"delete": "You can't delete your own account."}[change]
|
||
if target.role == UserRole.admin and target.is_active and active_admins <= 1:
|
||
return f"{target.username} is the only active admin. Make someone else an admin first."
|
||
return None
|
||
|
||
|
||
def password_problem(password: str, confirm: str) -> str | None:
|
||
if len(password) < MIN_PASSWORD:
|
||
return f"A password needs at least {MIN_PASSWORD} characters."
|
||
if password != confirm:
|
||
return "The two passwords don't match."
|
||
return None
|
||
|
||
|
||
def _hash(password: str) -> str:
|
||
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||
|
||
|
||
async def _active_admins(db) -> int:
|
||
return (await db.execute(select(func.count()).select_from(User).where(
|
||
User.role == UserRole.admin, User.is_active.is_(True)))).scalar_one()
|
||
|
||
|
||
async def _last_sign_ins(db) -> dict[str, datetime]:
|
||
"""Each user's newest sign-in, from the audit log (kept as long as it is)."""
|
||
rows = await db.execute(select(AuditEvent.user_id, func.max(AuditEvent.timestamp))
|
||
.where(AuditEvent.action == "auth.login", AuditEvent.user_id.isnot(None))
|
||
.group_by(AuditEvent.user_id))
|
||
return dict(rows.all())
|
||
|
||
|
||
async def _token_counts(db) -> dict[str, int]:
|
||
"""Each user's AI access tokens that still work."""
|
||
now = datetime.now(timezone.utc)
|
||
rows = await db.execute(select(ApiToken.user_id, func.count()).where(
|
||
ApiToken.revoked_at.is_(None),
|
||
or_(ApiToken.expires_at.is_(None), ApiToken.expires_at > now)).group_by(ApiToken.user_id))
|
||
return dict(rows.all())
|
||
|
||
|
||
async def held_by(db, user_id: str) -> list[str]:
|
||
"""What keeps an account from being deleted: records that name who made
|
||
them and must keep naming them, in words ("3 alert rules")."""
|
||
counts = [
|
||
(await db.execute(select(func.count()).where(AlertRule.created_by == user_id))).scalar_one(),
|
||
(await db.execute(select(func.count()).where(MaintenanceWindow.created_by == user_id))).scalar_one(),
|
||
(await db.execute(select(func.count()).where(AnsibleRun.triggered_by == user_id))).scalar_one(),
|
||
]
|
||
words = (("alert rule", "alert rules"), ("maintenance window", "maintenance windows"),
|
||
("playbook run", "playbook runs"))
|
||
return [f"{n} {one if n == 1 else many}" for n, (one, many) in zip(counts, words) if n]
|
||
|
||
|
||
async def _target(db, user_id: str) -> User:
|
||
user = await db.get(User, user_id)
|
||
if user is None:
|
||
abort(404)
|
||
return user
|
||
|
||
|
||
@users_bp.get("/")
|
||
@require_role(UserRole.admin)
|
||
async def users_list():
|
||
async with current_app.db_sessionmaker() as db:
|
||
users = (await db.execute(select(User))).scalars().all()
|
||
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),
|
||
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):
|
||
return await render_template("settings/user_new.html", error=error, form=form or {},
|
||
roles=list(UserRole), min_password=MIN_PASSWORD), status
|
||
|
||
|
||
@users_bp.get("/new")
|
||
@require_role(UserRole.admin)
|
||
async def user_new():
|
||
return await _new_page()
|
||
|
||
|
||
@users_bp.post("/new")
|
||
@require_role(UserRole.admin)
|
||
async def user_create():
|
||
form = await request.form
|
||
username = (form.get("username") or "").strip()
|
||
email = (form.get("email") or "").strip()
|
||
try:
|
||
role = UserRole(form.get("role") or "viewer")
|
||
except ValueError:
|
||
return await _new_page("Pick a role.", form, 400)
|
||
if not username or not email:
|
||
return await _new_page("A username and an email are both needed.", form, 400)
|
||
if len(username) > 64 or len(email) > 255:
|
||
return await _new_page("That username or email is too long.", form, 400)
|
||
problem = password_problem(form.get("password") or "", form.get("confirm") or "")
|
||
if problem:
|
||
return await _new_page(problem, form, 400)
|
||
user = User(username=username, email=email, role=role, auth_source="local",
|
||
password_hash=_hash(form.get("password")))
|
||
try:
|
||
async with current_app.db_sessionmaker() as db:
|
||
async with db.begin():
|
||
db.add(user)
|
||
except IntegrityError:
|
||
return await _new_page(f"An account already uses the username {username} or that email.", form, 400)
|
||
await audit_request("user.created", "user", user.id, {"username": username, "role": role.value})
|
||
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")))
|
||
from steward.core import outbox
|
||
(row,) = await outbox.send_each(current_app, "invite", [invite.email],
|
||
lambda _: (subject, text, html))
|
||
if row.state == "sent":
|
||
return None
|
||
if row.state == "pending":
|
||
return (f"Sending to {invite.email} didn't go through yet ({row.last_error}); "
|
||
f"Steward will keep trying for up to an hour.")
|
||
return f"Sending to {invite.email} failed ({row.last_error})."
|
||
|
||
|
||
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. Anywhere they were signed in, they are signed out.",
|
||
"disabled": "Account disabled. They are signed out at their next click.",
|
||
"enabled": "Account enabled."}
|
||
|
||
|
||
async def _user_page(user_id: str, error: str | None = None, status: int = 200):
|
||
async with current_app.db_sessionmaker() as db:
|
||
user = await _target(db, user_id)
|
||
admins = await _active_admins(db)
|
||
last = (await _last_sign_ins(db)).get(user.id)
|
||
tokens = (await _token_counts(db)).get(user.id, 0)
|
||
holds = await held_by(db, user.id)
|
||
defaults = mail.role_defaults(user, await get_all_settings(db))
|
||
me = session.get("user_id")
|
||
return await render_template(
|
||
"settings/user.html", user=user, last_sign_in=last, tokens=tokens, holds=holds,
|
||
defaults=defaults, reachable=mail.is_real_address(user.email),
|
||
source=SOURCES.get(user.auth_source, user.auth_source), roles=list(UserRole),
|
||
min_password=MIN_PASSWORD, error=error, done=_DONE.get(request.args.get("done", "")),
|
||
demote_refused=refusal("demote", user, me, admins),
|
||
disable_refused=refusal("disable", user, me, admins) if user.is_active else None,
|
||
delete_refused=refusal("delete", user, me, admins)), status
|
||
|
||
|
||
@users_bp.get("/<user_id>")
|
||
@require_role(UserRole.admin)
|
||
async def user_detail(user_id: str):
|
||
return await _user_page(user_id)
|
||
|
||
|
||
@users_bp.post("/<user_id>")
|
||
@require_role(UserRole.admin)
|
||
async def user_save(user_id: str):
|
||
"""The page's one form, for any account: its username and email, and
|
||
whether it is emailed alerts and the weekly report (milestone 495). The
|
||
address may be a provider's placeholder, so it isn't held to mail's test
|
||
here; the account just gets no email until it is real."""
|
||
form = await request.form
|
||
username = (form.get("username") or "").strip()
|
||
email = (form.get("email") or "").strip()
|
||
if not username or not email or len(username) > 64 or len(email) > 255:
|
||
return await _user_page(user_id, "A username (up to 64 characters) and an email are both needed.", 400)
|
||
switches = {k: v for k, v in form.items() if k != "email"}
|
||
try:
|
||
async with current_app.db_sessionmaker() as db:
|
||
async with db.begin():
|
||
user = await _target(db, user_id)
|
||
emailed, error = mail.apply_email_form(user, switches)
|
||
if error:
|
||
raise ValueError(error)
|
||
before = {"username": user.username, "email": user.email}
|
||
user.username, user.email = username, email
|
||
except ValueError as exc:
|
||
return await _user_page(user_id, str(exc), 400)
|
||
except IntegrityError:
|
||
return await _user_page(user_id, f"Another account already uses the username {username} or that email.", 400)
|
||
after = {"username": username, "email": email}
|
||
if after != before:
|
||
await audit_request("user.renamed", "user", user_id,
|
||
{k: v for k, v in after.items() if before[k] != v}
|
||
| {f"old_{k}": before[k] for k in after if before[k] != after[k]})
|
||
if user_id == session.get("user_id"):
|
||
session["username"] = username
|
||
if emailed:
|
||
await audit_request("user.email_settings", "user", user_id, {"username": username} | emailed)
|
||
return redirect(f"/settings/users/{user_id}?done=saved")
|
||
|
||
|
||
@users_bp.post("/<user_id>/role")
|
||
@require_role(UserRole.admin)
|
||
async def user_role(user_id: str):
|
||
try:
|
||
role = UserRole((await request.form).get("role") or "")
|
||
except ValueError:
|
||
return await _user_page(user_id, "Pick a role.", 400)
|
||
async with current_app.db_sessionmaker() as db:
|
||
async with db.begin():
|
||
user = await _target(db, user_id)
|
||
if user.auth_source != "local":
|
||
error = f"{SOURCES[user.auth_source]} sets this account's role at each sign-in."
|
||
elif role != UserRole.admin and user.role == UserRole.admin:
|
||
error = refusal("demote", user, session.get("user_id"), await _active_admins(db))
|
||
else:
|
||
error = None
|
||
if error is None:
|
||
before, user.role = user.role, role
|
||
if error:
|
||
return await _user_page(user_id, error, 400)
|
||
if before != role:
|
||
await audit_request("user.role_changed", "user", user_id,
|
||
{"username": user.username, "from": before.value, "to": role.value})
|
||
return redirect(f"/settings/users/{user_id}?done=role")
|
||
|
||
|
||
@users_bp.post("/<user_id>/password")
|
||
@require_role(UserRole.admin)
|
||
async def user_password(user_id: str):
|
||
form = await request.form
|
||
problem = password_problem(form.get("password") or "", form.get("confirm") or "")
|
||
async with current_app.db_sessionmaker() as db:
|
||
async with db.begin():
|
||
user = await _target(db, user_id)
|
||
if user.auth_source != "local":
|
||
problem = f"This account signs in with {SOURCES[user.auth_source]}; it has no Steward password."
|
||
if problem is None:
|
||
user.password_hash = _hash(form.get("password"))
|
||
# Every browser signed in with the old password is signed out (#5427).
|
||
user.sessions_since = datetime.now(timezone.utc)
|
||
if problem:
|
||
return await _user_page(user_id, problem, 400)
|
||
if user_id == session.get("user_id"):
|
||
session["login_at"] = time.time() # this session carries on under the new password
|
||
await audit_request("user.password_reset", "user", user_id, {"username": user.username})
|
||
return redirect(f"/settings/users/{user_id}?done=password")
|
||
|
||
|
||
@users_bp.post("/<user_id>/active")
|
||
@require_role(UserRole.admin)
|
||
async def user_active(user_id: str):
|
||
"""Disable an active account, or enable a disabled one. A disabled user is
|
||
signed out at their next request and their AI tokens stop working."""
|
||
async with current_app.db_sessionmaker() as db:
|
||
async with db.begin():
|
||
user = await _target(db, user_id)
|
||
error = (refusal("disable", user, session.get("user_id"), await _active_admins(db))
|
||
if user.is_active else None)
|
||
if error is None:
|
||
user.is_active = not user.is_active
|
||
if error:
|
||
return await _user_page(user_id, error, 400)
|
||
action = "enabled" if user.is_active else "disabled"
|
||
await audit_request(f"user.{action}", "user", user_id, {"username": user.username})
|
||
return redirect(f"/settings/users/{user_id}?done={action}")
|
||
|
||
|
||
@users_bp.post("/<user_id>/delete")
|
||
@require_role(UserRole.admin)
|
||
async def user_delete(user_id: str):
|
||
async with current_app.db_sessionmaker() as db:
|
||
async with db.begin():
|
||
user = await _target(db, user_id)
|
||
error = refusal("delete", user, session.get("user_id"), await _active_admins(db))
|
||
holds = await held_by(db, user_id)
|
||
if error is None and holds:
|
||
error = (f"{user.username} made {', '.join(holds)}, and Steward keeps who did. "
|
||
"Disable the account instead.")
|
||
if error is None:
|
||
await db.delete(user)
|
||
if error:
|
||
return await _user_page(user_id, error, 400)
|
||
await audit_request("user.deleted", "user", user_id, {"username": user.username})
|
||
return redirect("/settings/users/")
|