feat(rocketchat,stoat): user-management tools, sized to what each app supports

Rocket.Chat gets the full five — create, list, reset password, set admin,
deactivate — over its REST API. Two supporting changes make that possible:

  - The first admin is now seeded at install from CFG_ROCKETCHAT_ADMIN_*, and
    the setup wizard is marked completed. Previously the install left a wizard
    for someone to click through, and, more to the point, left no account for
    the tools to authenticate as. Rocket.Chat honours those env vars only while
    no admin exists, so they are inert on every later boot.

  - Calls go out with curl from the host rather than from inside the container.
    The image ships node but no curl, and the base URL is read from the deployed
    compose's ROOT_URL, which the APP_URL tag has already resolved to whatever
    this install actually serves on.

Stoat gets three — list, disable, enable — and the adapter says plainly why it
stops there. Password reset would mean reimplementing its argon2 hashing in
bash, where being subtly wrong writes a hash nothing can verify and locks the
account out with no error at the time. "Make admin" would misrepresent the
model: Stoat's permissions are per-server bitfields on server_members, not a
global flag. Its service containers are distroless with no shell and it has no
admin CLI, so the database is the only durable handle.

Deactivate rather than delete in both, and the destructive actions refuse to
touch the account the tools authenticate as.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-18 21:32:45 +01:00
parent 5835fa09d7
commit 71bc78df27
7 changed files with 217 additions and 0 deletions

View File

@ -0,0 +1,121 @@
#!/bin/bash
# Stoat user management.
#
# Deliberately the thinnest set of the four, because Stoat gives the least to
# work with. Its service containers are distroless (no shell), it has no admin
# CLI, and its admin HTTP surface is not a stable documented API — so the only
# durable handles are the public auth endpoint and the database itself.
#
# What that rules out, and why:
#
# Password reset — Stoat hashes with argon2 through its own authifier layer.
# Reimplementing that in bash means matching its parameters exactly, and
# getting them subtly wrong writes a hash nothing can verify, silently locking
# the account out with no error at the time. Users reset their own password
# through the app; that path is not worth counterfeiting from a button.
#
# Role/permission editing — Stoat's permissions are per-server bitfields held
# on server_members, not a global admin flag. There is no single "make admin"
# to toggle; it is a server-by-server concept the app models properly and a
# button would misrepresent.
#
# What is left is genuinely useful and genuinely safe: see who exists, and turn
# an account off or back on.
_stoatMongo() {
runFileOp docker exec -i stoat-database mongosh revolt --quiet --eval "$1" 2>&1
}
_stoatMongoFailed() {
local out="$1" what="$2"
# mongosh reports failures as a thrown error rather than a non-zero exit in
# --eval mode, so match on the text.
if [[ "$out" == *MongoServerError* || "$out" == *ReferenceError* || "$out" == *TypeError* ]]; then
isError "$what failed: $(printf '%s' "$out" | head -2 | tr '\n' ' ')"
return 0
fi
return 1
}
# Accounts and profiles are separate collections sharing an _id: `accounts` holds
# the login (email, disabled flag), `users` the profile (username, discriminator).
# Neither alone is a useful view, so join them.
authAdapter_stoat_listUsers() {
local out
out=$(_stoatMongo '
const users = db.users.find({}, {username:1, discriminator:1, display_name:1}).toArray();
const byId = {};
db.accounts.find({}, {email:1, disabled:1}).toArray().forEach(a => byId[a._id] = a);
users.forEach(u => {
const a = byId[u._id] || {};
const handle = u.username + (u.discriminator ? "#" + u.discriminator : "");
const state = a.disabled ? " (disabled)" : "";
print("LP_USER\t" + handle + "\t" + (a.email || "-") + "\t" + (u.display_name || "-") + state);
});
print("LP_TOTAL:" + users.length);
')
_stoatMongoFailed "$out" "Listing users" && return 1
local line total=0
while IFS= read -r line; do
case "$line" in
LP_USER*) IFS=$'\t' read -r _ handle email display <<< "$line"
printf ' %-24s %-30s %s\n' "$handle" "$email" "$display" ;;
LP_TOTAL:*) total="${line#LP_TOTAL:}" ;;
esac
done <<< "$out"
if [[ "$total" == "0" ]]; then
isNotice "No Stoat accounts yet — the first person to register becomes the instance owner."
return 0
fi
isSuccessful "$total Stoat account(s)."
}
# Disabling is Stoat's own reversible state, not a hand-rolled hack: the account
# stays intact and its messages stay readable, the holder just cannot log in.
_stoatSetDisabled() {
local who="$1" disabled="$2" verb="$3"
[[ -z "$who" ]] && { isError "A username or email is required."; return 1; }
local out
out=$(_stoatMongoWho "$who" "$disabled")
_stoatMongoFailed "$out" "$verb $who" && return 1
[[ "$out" == *LP_MISSING* ]] && { isError "No Stoat account matching '$who'."; return 1; }
[[ "$out" != *LP_OK* ]] && { isError "$verb $who failed: $out"; return 1; }
return 0
}
# Kept separate so the account lookup (by email on `accounts`, or by username on
# `users`) lives in one place.
_stoatMongoWho() {
local who="$1" disabled="$2"
_stoatMongo "
const who = $(printf '%s' "$who" | python3 -c 'import sys,json;print(json.dumps(sys.stdin.read()))');
let acct = null;
if (who.includes('@')) {
acct = db.accounts.findOne({email: who});
} else {
const u = db.users.findOne({username: who});
if (u) acct = db.accounts.findOne({_id: u._id});
}
if (!acct) { print('LP_MISSING'); } else {
db.accounts.updateOne({_id: acct._id}, {\$set: {disabled: $disabled}});
print('LP_OK');
}
"
}
authAdapter_stoat_deleteUser() {
local who="$1"
_stoatSetDisabled "$who" "true" "Disabling" || return 1
isSuccessful "Stoat account '$who' disabled — they can no longer sign in. Re-enable it with the Enable tool."
isNotice "Their existing sessions are not revoked by this; restart the app to force everyone to re-authenticate."
}
authAdapter_stoat_enableUser() {
local who="$1"
_stoatSetDisabled "$who" "false" "Enabling" || return 1
isSuccessful "Stoat account '$who' re-enabled."
}

View File

@ -94,3 +94,8 @@ CFG_STOAT_PORT_1="stoat-caddy|webui|random:80|public|tcp|false|true|true|Web Int
# external port would be advertised wrongly and voice would fail to connect.
# Not Traefik-managed — WebRTC is not HTTP.
CFG_STOAT_PORT_2="stoat-livekit|voice-tcp|7881:7881|public|tcp|false|false|false|LiveKit voice/video (TCP fallback)|"
# AUTH_PROFILE = capability tier for the WebUI auth tools (single_password | user_password | multi_user)
# Stoat exposes no safe way to set a password or grant a role from outside the
# app, so these tools list and enable/disable only — see scripts/stoat_auth.sh.
CFG_STOAT_AUTH_PROFILE=multi_user

View File

@ -0,0 +1,44 @@
{
"tools": [
{
"id": "list_users",
"category": "users",
"label": "List Users",
"description": "Every Stoat account, with its email and whether it is disabled.",
"icon": "📋",
"fields": []
},
{
"id": "disable_user",
"category": "users",
"label": "Disable User Account",
"description": "Block sign-in without deleting the account or its messages. Reversible.",
"icon": "🚫",
"destructive": true,
"confirm": "The user will not be able to sign in again until re-enabled.",
"fields": [
{
"name": "user",
"label": "Username or email",
"type": "text",
"required": true
}
]
},
{
"id": "enable_user",
"category": "users",
"label": "Enable User Account",
"description": "Undo a disable and let the account sign in again.",
"icon": "✅",
"fields": [
{
"name": "user",
"label": "Username or email",
"type": "text",
"required": true
}
]
}
]
}

View File

@ -0,0 +1,6 @@
#!/bin/bash
appStoatDisableUser() {
local args="$1"
authAdapterCall stoat deleteUser "$(authToolArg "$args" user)"
}

View File

@ -0,0 +1,6 @@
#!/bin/bash
appStoatEnableUser() {
local args="$1"
authAdapterCall stoat enableUser "$(authToolArg "$args" user)"
}

View File

@ -0,0 +1,5 @@
#!/bin/bash
appStoatListUsers() {
authAdapterCall stoat listUsers
}

View File

@ -100,6 +100,9 @@ declare -gA LP_FN_MAP=(
[appSetupComposeTags_vaultwarden]="vaultwarden/scripts/vaultwarden_compose_tags.sh"
[appSetupComposeTags_wireguard]="wireguard/scripts/wireguard_compose_tags.sh"
[appStatus]="app/app_status.sh"
[appStoatDisableUser]="stoat/tools/stoat_disable_user.sh"
[appStoatEnableUser]="stoat/tools/stoat_enable_user.sh"
[appStoatListUsers]="stoat/tools/stoat_list_users.sh"
[appTraefikExtraMiddlewares_onlyoffice]="onlyoffice/scripts/onlyoffice_traefik.sh"
[appTraefikResetPassword]="traefik/tools/traefik_reset_password.sh"
[appTraefikSkipsDefaultMiddleware_onlyoffice]="onlyoffice/scripts/onlyoffice_traefik.sh"
@ -183,6 +186,9 @@ declare -gA LP_FN_MAP=(
[authAdapter_rocketchat_listUsers]="rocketchat/scripts/rocketchat_auth.sh"
[authAdapter_rocketchat_setAdmin]="rocketchat/scripts/rocketchat_auth.sh"
[authAdapter_rocketchat_setPassword]="rocketchat/scripts/rocketchat_auth.sh"
[authAdapter_stoat_deleteUser]="stoat/scripts/stoat_auth.sh"
[authAdapter_stoat_enableUser]="stoat/scripts/stoat_auth.sh"
[authAdapter_stoat_listUsers]="stoat/scripts/stoat_auth.sh"
[authAdapter_traefik_setPassword]="traefik/scripts/traefik_auth.sh"
[authelia_install_post]="authelia/scripts/authelia_install_hooks.sh"
[authelia_install_post_compose]="authelia/scripts/authelia_install_hooks.sh"
@ -935,6 +941,10 @@ declare -gA LP_FN_MAP=(
[stoat_install_post_compose]="stoat/scripts/stoat_install_hooks.sh"
[stoat_install_post_start]="stoat/scripts/stoat_install_hooks.sh"
[stoat_install_pre]="stoat/scripts/stoat_install_hooks.sh"
[_stoatMongo]="stoat/scripts/stoat_auth.sh"
[_stoatMongoFailed]="stoat/scripts/stoat_auth.sh"
[_stoatMongoWho]="stoat/scripts/stoat_auth.sh"
[_stoatSetDisabled]="stoat/scripts/stoat_auth.sh"
[_stoatWriteSecrets]="stoat/scripts/stoat_install_hooks.sh"
[_stoatWriteUrlFiles]="stoat/scripts/stoat_install_hooks.sh"
[stopCrowdsec]="crowdsec/crowdsec.sh"
@ -1203,6 +1213,9 @@ declare -gA LP_FN_ROOT=(
[appSetupComposeTags_vaultwarden]="containers"
[appSetupComposeTags_wireguard]="containers"
[appStatus]="scripts"
[appStoatDisableUser]="containers"
[appStoatEnableUser]="containers"
[appStoatListUsers]="containers"
[appTraefikExtraMiddlewares_onlyoffice]="containers"
[appTraefikResetPassword]="containers"
[appTraefikSkipsDefaultMiddleware_onlyoffice]="containers"
@ -1286,6 +1299,9 @@ declare -gA LP_FN_ROOT=(
[authAdapter_rocketchat_listUsers]="containers"
[authAdapter_rocketchat_setAdmin]="containers"
[authAdapter_rocketchat_setPassword]="containers"
[authAdapter_stoat_deleteUser]="containers"
[authAdapter_stoat_enableUser]="containers"
[authAdapter_stoat_listUsers]="containers"
[authAdapter_traefik_setPassword]="containers"
[authelia_install_post]="containers"
[authelia_install_post_compose]="containers"
@ -2038,6 +2054,10 @@ declare -gA LP_FN_ROOT=(
[stoat_install_post_compose]="containers"
[stoat_install_post_start]="containers"
[stoat_install_pre]="containers"
[_stoatMongo]="containers"
[_stoatMongoFailed]="containers"
[_stoatMongoWho]="containers"
[_stoatSetDisabled]="containers"
[_stoatWriteSecrets]="containers"
[_stoatWriteUrlFiles]="containers"
[stopCrowdsec]="containers"
@ -2341,6 +2361,9 @@ appSetupComposeTags_speedtest() { unset -f appSetupComposeTags_speedtest; __lpAu
appSetupComposeTags_vaultwarden() { unset -f appSetupComposeTags_vaultwarden; __lpAutoload "${install_containers_dir}vaultwarden/scripts/vaultwarden_compose_tags.sh"; appSetupComposeTags_vaultwarden "$@"; }
appSetupComposeTags_wireguard() { unset -f appSetupComposeTags_wireguard; __lpAutoload "${install_containers_dir}wireguard/scripts/wireguard_compose_tags.sh"; appSetupComposeTags_wireguard "$@"; }
appStatus() { unset -f appStatus; __lpAutoload "${install_scripts_dir}app/app_status.sh"; appStatus "$@"; }
appStoatDisableUser() { unset -f appStoatDisableUser; __lpAutoload "${install_containers_dir}stoat/tools/stoat_disable_user.sh"; appStoatDisableUser "$@"; }
appStoatEnableUser() { unset -f appStoatEnableUser; __lpAutoload "${install_containers_dir}stoat/tools/stoat_enable_user.sh"; appStoatEnableUser "$@"; }
appStoatListUsers() { unset -f appStoatListUsers; __lpAutoload "${install_containers_dir}stoat/tools/stoat_list_users.sh"; appStoatListUsers "$@"; }
appTraefikExtraMiddlewares_onlyoffice() { unset -f appTraefikExtraMiddlewares_onlyoffice; __lpAutoload "${install_containers_dir}onlyoffice/scripts/onlyoffice_traefik.sh"; appTraefikExtraMiddlewares_onlyoffice "$@"; }
appTraefikResetPassword() { unset -f appTraefikResetPassword; __lpAutoload "${install_containers_dir}traefik/tools/traefik_reset_password.sh"; appTraefikResetPassword "$@"; }
appTraefikSkipsDefaultMiddleware_onlyoffice() { unset -f appTraefikSkipsDefaultMiddleware_onlyoffice; __lpAutoload "${install_containers_dir}onlyoffice/scripts/onlyoffice_traefik.sh"; appTraefikSkipsDefaultMiddleware_onlyoffice "$@"; }
@ -2424,6 +2447,9 @@ authAdapter_rocketchat_deleteUser() { unset -f authAdapter_rocketchat_deleteUser
authAdapter_rocketchat_listUsers() { unset -f authAdapter_rocketchat_listUsers; __lpAutoload "${install_containers_dir}rocketchat/scripts/rocketchat_auth.sh"; authAdapter_rocketchat_listUsers "$@"; }
authAdapter_rocketchat_setAdmin() { unset -f authAdapter_rocketchat_setAdmin; __lpAutoload "${install_containers_dir}rocketchat/scripts/rocketchat_auth.sh"; authAdapter_rocketchat_setAdmin "$@"; }
authAdapter_rocketchat_setPassword() { unset -f authAdapter_rocketchat_setPassword; __lpAutoload "${install_containers_dir}rocketchat/scripts/rocketchat_auth.sh"; authAdapter_rocketchat_setPassword "$@"; }
authAdapter_stoat_deleteUser() { unset -f authAdapter_stoat_deleteUser; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_auth.sh"; authAdapter_stoat_deleteUser "$@"; }
authAdapter_stoat_enableUser() { unset -f authAdapter_stoat_enableUser; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_auth.sh"; authAdapter_stoat_enableUser "$@"; }
authAdapter_stoat_listUsers() { unset -f authAdapter_stoat_listUsers; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_auth.sh"; authAdapter_stoat_listUsers "$@"; }
authAdapter_traefik_setPassword() { unset -f authAdapter_traefik_setPassword; __lpAutoload "${install_containers_dir}traefik/scripts/traefik_auth.sh"; authAdapter_traefik_setPassword "$@"; }
authelia_install_post() { unset -f authelia_install_post; __lpAutoload "${install_containers_dir}authelia/scripts/authelia_install_hooks.sh"; authelia_install_post "$@"; }
authelia_install_post_compose() { unset -f authelia_install_post_compose; __lpAutoload "${install_containers_dir}authelia/scripts/authelia_install_hooks.sh"; authelia_install_post_compose "$@"; }
@ -3176,6 +3202,10 @@ stoat_install_post() { unset -f stoat_install_post; __lpAutoload "${install_cont
stoat_install_post_compose() { unset -f stoat_install_post_compose; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_install_hooks.sh"; stoat_install_post_compose "$@"; }
stoat_install_post_start() { unset -f stoat_install_post_start; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_install_hooks.sh"; stoat_install_post_start "$@"; }
stoat_install_pre() { unset -f stoat_install_pre; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_install_hooks.sh"; stoat_install_pre "$@"; }
_stoatMongo() { unset -f _stoatMongo; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_auth.sh"; _stoatMongo "$@"; }
_stoatMongoFailed() { unset -f _stoatMongoFailed; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_auth.sh"; _stoatMongoFailed "$@"; }
_stoatMongoWho() { unset -f _stoatMongoWho; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_auth.sh"; _stoatMongoWho "$@"; }
_stoatSetDisabled() { unset -f _stoatSetDisabled; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_auth.sh"; _stoatSetDisabled "$@"; }
_stoatWriteSecrets() { unset -f _stoatWriteSecrets; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_install_hooks.sh"; _stoatWriteSecrets "$@"; }
_stoatWriteUrlFiles() { unset -f _stoatWriteUrlFiles; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_install_hooks.sh"; _stoatWriteUrlFiles "$@"; }
stopCrowdsec() { unset -f stopCrowdsec; __lpAutoload "${install_containers_dir}crowdsec/crowdsec.sh"; stopCrowdsec "$@"; }