feat(settings): Users page — list, add, rename, role, password, disable, delete (#5336)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m3s
CI / unit (push) Successful in 1m31s
CI / publish (push) Successful in 1m21s

#5336 "Users page: list accounts, change role, disable, reset a local
password, create a local user".

- Settings › Users (admin only, a Settings tab): every account with how it
  signs in, its role, last sign-in (from the audit log) and working AI
  tokens; disabled accounts last. A row opens the account.
- An account's page: rename (any account), role and password (local
  accounts; an LDAP or single sign-on role comes from the provider at
  every sign-in, and the page says so), disable or enable, and Delete
  behind the confirm dialog.
- Refusals are said beside the control: nobody disables, demotes or
  deletes themselves, the last active admin stays, and an account that
  made alert rules, maintenance windows or playbook runs is disabled
  rather than deleted (those records keep who made them).
- Add user: a local account with a role and a password of 8+ characters.
- Every change is audited as user.*, and the audit log names and links the
  account.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 14:12:56 -04:00
co-authored by Claude Opus 5.5
parent ec55faf737
commit c533eec33a
9 changed files with 710 additions and 2 deletions
+2
View File
@@ -215,6 +215,7 @@ def create_app(
from .ansible.routes import ansible_bp
from .ansible.inventory_routes import inventory_bp
from .settings.routes import settings_bp
from .settings.users import users_bp
from .audit.routes import audit_bp
from .ai_access.routes import ai_access_bp
from .account.routes import account_bp
@@ -228,6 +229,7 @@ def create_app(
app.register_blueprint(ansible_bp)
app.register_blueprint(inventory_bp)
app.register_blueprint(settings_bp)
app.register_blueprint(users_bp)
app.register_blueprint(audit_bp)
app.register_blueprint(ai_access_bp)
app.register_blueprint(account_bp)
+12 -2
View File
@@ -19,6 +19,7 @@ from steward.models.ansible_schedule import AnsibleSchedule
from steward.models.api_tokens import ApiToken
from steward.models.hosts import Host
from steward.models.monitors import Monitor
from steward.models.users import User
logger = logging.getLogger(__name__)
@@ -33,6 +34,7 @@ _NAMED = {
"ansible_group": (AnsibleGroup, "/ansible/inventory/groups/{}"),
"ansible_schedule": (AnsibleSchedule, "/ansible/schedules"),
"api_token": (ApiToken, "/ai-access/"),
"user": (User, "/settings/users/{}"),
}
_SELF_NAMED = {"plugin": "/settings/plugins/", "ansible_playbook": None}
@@ -80,8 +82,9 @@ async def entity_labels(db, items: list[dict]) -> dict[tuple[str, str], dict]:
keys = [int(i) for i in wanted if i.isdigit()] if etype == "api_token" else list(wanted)
if not keys:
continue
name_col = model.username if model is User else model.name
for row_id, name in (await db.execute(
select(key_col, model.name).where(key_col.in_(keys)))).all():
select(key_col, name_col).where(key_col.in_(keys)))).all():
labels[(etype, str(row_id))] = {"label": name, "url": page.format(row_id), "gone": False}
elif etype == "ansible_run":
runs = (await db.execute(
@@ -116,6 +119,13 @@ ACTIONS = {
"auth.login_failed": "failed to sign in",
"auth.setup": "set up Steward",
"account.time_zone": "set their time zone",
"user.created": "added user",
"user.renamed": "renamed user",
"user.role_changed": "changed the role of user",
"user.password_reset": "set a new password for user",
"user.disabled": "disabled user",
"user.enabled": "enabled user",
"user.deleted": "deleted user",
"host.created": "added host",
"host.renamed": "renamed host",
"host.deleted": "deleted host",
@@ -170,7 +180,7 @@ ACTIONS = {
}
# The filter's choices: an area, matched as a prefix of the action code.
AREAS = (("auth.", "Sign-ins"), ("account.", "Accounts"), ("host.", "Hosts"), ("monitor.", "Monitors"),
AREAS = (("auth.", "Sign-ins"), ("account.", "Accounts"), ("user.", "Users"), ("host.", "Hosts"), ("monitor.", "Monitors"),
("alert_rule.", "Alert rules"), ("maintenance_window.", "Maintenance"),
("ansible.run", "Playbook runs"), ("ansible.", "Ansible (all)"),
("settings.", "Settings"), ("plugin.", "Plugins"), ("api_token.", "AI access"),
+287
View File
@@ -0,0 +1,287 @@
"""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
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.audit import audit_request
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.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)
users = sorted(users, key=lambda u: (not u.is_active, u.username.lower()))
return await render_template(
"settings/users.html", users=users, last=last, tokens=tokens, sources=SOURCES,
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))
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")
_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.",
"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)
me = session.get("user_id")
return await render_template(
"settings/user.html", user=user, last_sign_in=last, tokens=tokens, holds=holds,
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):
"""Rename: the username and email, for any account."""
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)
try:
async with current_app.db_sessionmaker() as db:
async with db.begin():
user = await _target(db, user_id)
before = {"username": user.username, "email": user.email}
user.username, user.email = username, email
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
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"))
if problem:
return await _user_page(user_id, problem, 400)
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/")
+1
View File
@@ -8,6 +8,7 @@
("notifications", "Notifications", "/settings/notifications/"),
("reports", "Reports", "/settings/reports/"),
("auth", "Authentication", "/settings/auth/"),
("users", "Users", "/settings/users/"),
("ansible", "Ansible", "/settings/ansible/"),
("plugins", "Plugins", "/settings/plugins/")], active, "Settings") }}
{%- endmacro %}
+116
View File
@@ -0,0 +1,116 @@
{# settings/user.html — one account (#5336): who it is and how it signs in,
then its name, role and password, disabling it, and Delete at the foot.
A refusal (yourself, the last admin, records that name it) is said beside
the control rather than discovered by pressing it. #}
{% extends "base.html" %}
{% from "_macros.html" import crumbs, page_header, status_badge %}
{% from "settings/_tabs.html" import settings_tabs %}
{% block title %}{{ user.username }} — Users — Steward{% endblock %}
{% block section_tabs %}{{ settings_tabs("users") }}{% endblock %}
{% block breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("Users", "/settings/users/"), (user.username, "")]) }}{% endblock %}
{% block content %}
{% set local = user.auth_source == "local" %}
{{ page_header(user.username) }}
<p class="page-verdict">
{% if user.is_active %}{{ user.role.value|capitalize }}{% else %}{{ status_badge("inactive", "Disabled") }} {{ user.role.value }}{% endif %}
· signs in with {{ source }}
· {% if last_sign_in %}last signed in {{ last_sign_in|ago }}{% else %}<span class="tone-dim">no sign-in on record</span>{% endif %}
· {% if not tokens %}no AI tokens{% elif user.id == session.user_id %}<a href="/ai-access/">{{ tokens }} AI token{{ 's' if tokens != 1 }}</a>{% else %}{{ tokens }} AI token{{ 's' if tokens != 1 }}{% endif %}
· added {{ user.created_at|when }}
</p>
{% if error %}<div class="alert alert-error" role="alert">{{ error }}</div>
{% elif done %}<div class="alert alert-success" role="status">{{ done }}</div>{% endif %}
<form method="post" action="/settings/users/{{ user.id }}" class="form-page">{{ csrf_field() }}
<section class="card form-section" aria-labelledby="u-account">
<h2 id="u-account" class="section-title">Name</h2>
<div class="form-pair">
<div class="field">
<label class="field-label" for="u-username">Username</label>
<input class="input" id="u-username" type="text" name="username" maxlength="64" value="{{ user.username }}" required>
</div>
<div class="field">
<label class="field-label" for="u-email">Email</label>
<input class="input" id="u-email" type="email" name="email" maxlength="255" value="{{ user.email }}" required>
</div>
</div>
{% if not local %}<p class="field-hint">{{ source }} finds this account by its own id, not by name, so renaming it here does not stop it signing in.</p>{% endif %}
<button type="submit" class="btn">Save</button>
</section>
</form>
<section class="card form-section" aria-labelledby="u-role">
<h2 id="u-role" class="section-title">Role</h2>
{% if not local %}
<p>{{ user.role.value|capitalize }}. {{ source }} sets it from the account's groups at every sign-in; the mapping is in Settings › <a href="/settings/auth/">Authentication</a>.</p>
{% else %}
<form method="post" action="/settings/users/{{ user.id }}/role" class="inline-form">{{ csrf_field() }}
<select class="input" name="role" aria-label="Role">
{% for r in roles %}
<option value="{{ r.value }}" {% if user.role == r %}selected{% endif %}
{% if demote_refused and r.value != 'admin' %}disabled{% endif %}>{{ r.value|capitalize }}</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-ghost">Change role</button>
</form>
{% if demote_refused %}<p class="field-hint">{{ demote_refused }}</p>{% endif %}
{% endif %}
</section>
{% if local %}
<form method="post" action="/settings/users/{{ user.id }}/password" class="form-page">{{ csrf_field() }}
<section class="card form-section" aria-labelledby="u-password">
<h2 id="u-password" class="section-title">Password</h2>
<div class="form-pair">
<div class="field">
<label class="field-label" for="u-pw">New password</label>
<input class="input" id="u-pw" type="password" name="password" minlength="{{ min_password }}" required autocomplete="new-password">
<span class="field-hint">At least {{ min_password }} characters. Give it to them yourself; Steward sends no email for it.</span>
</div>
<div class="field">
<label class="field-label" for="u-pw2">Again</label>
<input class="input" id="u-pw2" type="password" name="confirm" required autocomplete="new-password">
</div>
</div>
<button type="submit" class="btn btn-ghost">Set password</button>
</section>
</form>
{% endif %}
<section class="card form-section" aria-labelledby="u-access">
<h2 id="u-access" class="section-title">{{ "Disable" if user.is_active else "Enable" }}</h2>
{% if user.is_active %}
<p class="field-hint">A disabled account is signed out at its next click and its AI tokens stop working. Everything it made stays.</p>
{% if disable_refused %}
<p class="field-hint">{{ disable_refused }}</p>
{% else %}
<form method="post" action="/settings/users/{{ user.id }}/active"
data-confirm="Disable {{ user.username }}? They are signed out at their next click and can't sign in until an admin enables the account.">{{ csrf_field() }}
<button type="submit" class="btn btn-ghost">Disable account</button>
</form>
{% endif %}
{% else %}
<p class="field-hint">This account can't sign in, and its AI tokens don't work.</p>
<form method="post" action="/settings/users/{{ user.id }}/active">{{ csrf_field() }}
<button type="submit" class="btn">Enable account</button>
</form>
{% endif %}
</section>
<section class="danger-zone" aria-labelledby="u-delete">
<h2 id="u-delete" class="section-title">Delete this account</h2>
{% if delete_refused %}
<p class="field-hint">{{ delete_refused }}</p>
{% elif holds %}
<p class="field-hint">{{ user.username }} made {{ holds|join(", ") }}, and Steward keeps who did, so this account can't be deleted. Disable it instead.</p>
{% else %}
<p class="field-hint">Its AI tokens go with it. The audit log keeps its name.</p>
<form method="post" action="/settings/users/{{ user.id }}/delete"
data-confirm="Delete {{ user.username }}? Their AI tokens go too. This can't be undone.">{{ csrf_field() }}
<button class="btn btn-danger" type="submit">Delete account</button>
</form>
{% endif %}
</section>
{% endblock %}
+54
View File
@@ -0,0 +1,54 @@
{# settings/user_new.html — add an account that signs in with a Steward password (#5336). #}
{% extends "base.html" %}
{% from "_macros.html" import crumbs, page_header %}
{% from "settings/_tabs.html" import settings_tabs %}
{% block title %}Add user — Settings — Steward{% endblock %}
{% block section_tabs %}{{ settings_tabs("users") }}{% endblock %}
{% block breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("Users", "/settings/users/"), ("Add user", "")]) }}{% endblock %}
{% block content %}
{{ page_header("Add user", "Signs in with a Steward password.") }}
{% if error %}<div class="alert alert-error" role="alert">{{ error }}</div>{% endif %}
<form method="post" action="/settings/users/new" class="form-page">{{ csrf_field() }}
<section class="card form-section" aria-labelledby="new-account">
<h2 id="new-account" class="section-title">Account</h2>
<div class="form-pair">
<div class="field">
<label class="field-label" for="new-username">Username</label>
<input class="input" id="new-username" type="text" name="username" maxlength="64"
value="{{ form.get('username', '') }}" required autocomplete="off">
</div>
<div class="field">
<label class="field-label" for="new-email">Email</label>
<input class="input" id="new-email" type="email" name="email" maxlength="255"
value="{{ form.get('email', '') }}" required autocomplete="off">
</div>
</div>
<div class="field">
<label class="field-label" for="new-role">Role</label>
<select class="input" id="new-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>
<span class="field-hint">A viewer reads; an operator also runs playbooks and changes monitors, hosts and alerts; an admin also changes Settings and users.</span>
</div>
</section>
<section class="card form-section" aria-labelledby="new-password">
<h2 id="new-password" class="section-title">Password</h2>
<div class="form-pair">
<div class="field">
<label class="field-label" for="new-pw">Password</label>
<input class="input" id="new-pw" type="password" name="password" minlength="{{ min_password }}" required autocomplete="new-password">
<span class="field-hint">At least {{ min_password }} characters. Give it to them yourself; Steward sends no email for it.</span>
</div>
<div class="field">
<label class="field-label" for="new-pw2">Again</label>
<input class="input" id="new-pw2" type="password" name="confirm" required autocomplete="new-password">
</div>
</div>
</section>
<div class="form-actions">
<button type="submit" class="btn">Add user</button>
<a href="/settings/users/" class="btn btn-ghost">Cancel</a>
</div>
</form>
{% endblock %}
+39
View File
@@ -0,0 +1,39 @@
{# settings/users.html — Settings › Users (#5336): every account, active ones
first. A row opens the account's page. #}
{% extends "base.html" %}
{% from "_macros.html" import crumbs, page_header, status_badge %}
{% from "settings/_tabs.html" import settings_tabs %}
{% block title %}Settings — Users — Steward{% endblock %}
{% 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 %}
<p class="page-verdict">
{{ users|length }} account{{ 's' if users|length != 1 }}
· {{ admins }} active admin{{ 's' if admins != 1 }}
{%- if disabled %} · <span class="tone-muted">{{ disabled }} disabled</span>{% endif %}
</p>
<div class="card-flush">
<table class="table">
<thead><tr><th>User</th><th>Signs in with</th><th>Role</th><th>Last sign-in</th><th class="num">AI tokens</th></tr></thead>
<tbody>
{% for u in users %}
<tr class="row-link{% if not u.is_active %} row-off{% endif %}">
<td class="cell-name">
<a class="row-target" href="/settings/users/{{ u.id }}">{{ u.username }}</a>
{%- if u.id == session.user_id %} <span class="tone-dim">(you)</span>{% endif %}
<div class="cell-sub">{{ u.email }}</div>
</td>
<td>{{ sources.get(u.auth_source, u.auth_source) }}</td>
<td>{{ u.role.value|capitalize }}{% if not u.is_active %} {{ status_badge("inactive", "Disabled") }}{% endif %}</td>
<td class="nowrap">{% if last.get(u.id) %}{{ last[u.id]|ago }}{% else %}—{% endif %}</td>
<td class="num">{{ tokens.get(u.id, 0) or "—" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<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 %}
+29
View File
@@ -0,0 +1,29 @@
"""Settings › Users keeps someone able to get back in (#5336): nobody can
disable, demote or delete their own account, and the last active admin
cannot be demoted or disabled."""
from steward.models.users import User, UserRole
from steward.settings.users import password_problem, refusal
def _u(uid, role=UserRole.admin, active=True):
return User(id=uid, username=uid, email=f"{uid}@t", password_hash="x", role=role, is_active=active)
def test_nobody_changes_their_own_access():
me = _u("me")
for change in ("demote", "disable", "delete"):
assert "own" in refusal(change, me, "me", active_admins=5)
def test_the_last_active_admin_stays():
only = _u("only")
assert "only active admin" in refusal("demote", only, "someone-else", active_admins=1)
assert refusal("demote", only, "someone-else", active_admins=2) is None
assert refusal("disable", _u("v", role=UserRole.viewer), "me", active_admins=1) is None
assert refusal("delete", _u("gone", active=False), "me", active_admins=1) is None
def test_a_password_is_long_enough_and_typed_twice():
assert "at least" in password_problem("short", "short")
assert "match" in password_problem("long enough", "long enougj")
assert password_problem("long enough", "long enough") is None
+170
View File
@@ -0,0 +1,170 @@
"""Integration: Settings › Users (#5336) against real Postgres. An admin adds
a local account, changes its role, sets its password, disables and deletes
it; their own account and an account that made records are refused, and
every change is audited."""
from __future__ import annotations
import asyncio
import json
import os
import time
import uuid
from datetime import datetime, timedelta, timezone
import bcrypt
import pytest
pytestmark = pytest.mark.integration
CSRF = {"X-CSRF-Token": "test-token"}
@pytest.fixture
def app():
if not os.environ.get("STEWARD_DATABASE_URL"):
pytest.skip("needs Postgres")
from steward.app import create_app
return create_app(testing=False)
async def _client(app, role="admin"):
from steward.models.users import User, UserRole
uid = str(uuid.uuid4())
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(User(id=uid, username=f"adm-{uid[:8]}", email=f"{uid}@test",
password_hash="x", role=UserRole(role), is_active=True))
client = app.test_client()
async with client.session_transaction() as sess:
sess.update(user_id=uid, user_role=role, username=f"adm-{uid[:8]}",
_csrf="test-token", login_at=time.time())
return client, uid
async def _user(app, user_id):
from steward.models.users import User
async with app.db_sessionmaker() as s:
return await s.get(User, user_id)
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 test_an_admin_adds_changes_and_deletes_a_local_account(app):
async def _go():
client, me = await _client(app)
name = f"new-{uuid.uuid4().hex[:8]}"
resp = await client.post("/settings/users/new", headers=CSRF, form={
"username": name, "email": f"{name}@test", "role": "viewer",
"password": "short", "confirm": "short"})
assert resp.status_code == 400 and "at least 8 characters" in await resp.get_data(as_text=True)
resp = await client.post("/settings/users/new", headers=CSRF, form={
"username": name, "email": f"{name}@test", "role": "viewer",
"password": "correct horse", "confirm": "correct horse"})
assert resp.status_code == 302
uid = resp.headers["Location"].split("/settings/users/")[1].split("?")[0]
user = await _user(app, uid)
assert (user.username, user.role.value, user.auth_source) == (name, "viewer", "local")
assert bcrypt.checkpw(b"correct horse", user.password_hash.encode())
page = await (await client.get("/settings/users/")).get_data(as_text=True)
assert f'href="/settings/users/{uid}"' in page and 'aria-label="Settings"' in page
assert (await client.post(f"/settings/users/{uid}/role", headers=CSRF,
form={"role": "operator"})).status_code == 302
assert (await _user(app, uid)).role.value == "operator"
resp = await client.post(f"/settings/users/{uid}/password", headers=CSRF,
form={"password": "battery staple", "confirm": "battery stapler"})
assert resp.status_code == 400 and "match" in await resp.get_data(as_text=True)
assert (await client.post(f"/settings/users/{uid}/password", headers=CSRF,
form={"password": "battery staple", "confirm": "battery staple"})).status_code == 302
assert bcrypt.checkpw(b"battery staple", (await _user(app, uid)).password_hash.encode())
assert (await client.post(f"/settings/users/{uid}/active", headers=CSRF)).status_code == 302
assert not (await _user(app, uid)).is_active
assert (await client.post(f"/settings/users/{uid}/active", headers=CSRF)).status_code == 302
assert (await _user(app, uid)).is_active
assert (await client.post(f"/settings/users/{uid}", headers=CSRF,
form={"username": name + "-x", "email": f"{name}@test"})).status_code == 302
assert (await _user(app, uid)).username == name + "-x"
assert (await client.post(f"/settings/users/{uid}/delete", headers=CSRF)).status_code == 302
assert await _user(app, uid) is None
assert [a for a, _ in await _audits(app, me)] == [
"user.created", "user.role_changed", "user.password_reset", "user.disabled",
"user.enabled", "user.renamed", "user.deleted"]
asyncio.run(_go())
def test_an_admin_cannot_disable_demote_or_delete_themselves(app):
async def _go():
client, me = await _client(app)
for path, form, says in ((f"/settings/users/{me}/active", {}, "your own account"),
(f"/settings/users/{me}/role", {"role": "viewer"}, "your own role"),
(f"/settings/users/{me}/delete", {}, "your own account")):
resp = await client.post(path, headers=CSRF, form=form)
assert resp.status_code == 400 and says in await resp.get_data(as_text=True), path
me_now = await _user(app, me)
assert me_now.is_active and me_now.role.value == "admin"
asyncio.run(_go())
def test_an_account_that_made_records_is_disabled_not_deleted(app):
from steward.models.alerts import MaintenanceWindow
async def _go():
client, _ = await _client(app)
_, other = await _client(app, role="operator")
now = datetime.now(timezone.utc)
async with app.db_sessionmaker() as s:
async with s.begin():
# Over already, so it silences no other test's alerts.
s.add(MaintenanceWindow(name="patching", start_at=now - timedelta(days=2),
end_at=now - timedelta(days=1), created_by=other))
page = await (await client.get(f"/settings/users/{other}")).get_data(as_text=True)
assert "1 maintenance window" in page and "Delete account</button>" not in page
resp = await client.post(f"/settings/users/{other}/delete", headers=CSRF)
assert resp.status_code == 400 and "Disable the account instead" in await resp.get_data(as_text=True)
assert await _user(app, other) is not None
asyncio.run(_go())
def test_a_provider_account_s_role_is_not_edited_here(app):
from steward.models.users import User, UserRole
async def _go():
client, _ = await _client(app)
uid = str(uuid.uuid4())
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(User(id=uid, username=f"sso-{uid[:8]}", email=f"{uid}@oidc", password_hash="x",
role=UserRole.viewer, auth_source="oidc", external_id=uid))
resp = await client.post(f"/settings/users/{uid}/role", headers=CSRF, form={"role": "admin"})
assert resp.status_code == 400 and "at each sign-in" in await resp.get_data(as_text=True)
resp = await client.post(f"/settings/users/{uid}/password", headers=CSRF,
form={"password": "long enough", "confirm": "long enough"})
assert resp.status_code == 400
assert (await _user(app, uid)).role.value == "viewer"
asyncio.run(_go())
def test_only_an_admin_reaches_users(app):
async def _go():
client, _ = await _client(app, role="operator")
assert (await client.get("/settings/users/")).status_code == 403
assert (await client.post("/settings/users/new", headers=CSRF, form={})).status_code == 403
asyncio.run(_go())