Three things running the tools against a live instance exposed: - roles.addUserToRole takes roleId + username and nothing else. Passing roleName fails schema validation with "must NOT have additional properties", and roleId + userId is refused for a missing username. Set admin was broken in both directions. - Rocket.Chat enables a password policy by default demanding lower, upper, digit AND special at 14+ characters, while generateRandomPassword is alphanumeric. Reset failed with "does not meet the server's password policy". Notably users.create does NOT enforce the policy, which is why creating an account worked and resetting the same account's password did not — an inconsistency worth knowing about rather than guessing at. Generated passwords now carry one character from each class appended, leaving the generated entropy untouched. - Deactivation had no counterpart, so "reversible from Admin → Users" was only true if you left the WebUI. Adds an Enable tool, matching Stoat's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
264 lines
11 KiB
Bash
264 lines
11 KiB
Bash
#!/bin/bash
|
|
|
|
# Rocket.Chat user management, via its REST API.
|
|
#
|
|
# Called with curl from the host rather than from inside the container: the
|
|
# Rocket.Chat image ships node but no curl or wget, and shelling out to node
|
|
# just to make an HTTP request would mean embedding JavaScript in bash for no
|
|
# benefit. The base URL is read from ROOT_URL in the deployed compose, which the
|
|
# APP_URL tag has already resolved to whatever this install actually serves on —
|
|
# https://host behind Traefik, http://ip:port on a LAN-only box.
|
|
#
|
|
# Authentication uses the admin seeded at install (CFG_ROCKETCHAT_ADMIN_*).
|
|
# Rocket.Chat has no local/socket admin path like Mattermost's, so there is no
|
|
# way round needing a real account here.
|
|
|
|
_rocketchatBaseUrl() {
|
|
local compose="${containers_dir}rocketchat/docker-compose.yml"
|
|
local url
|
|
url=$(runFileOp grep -oP '^\s*-\s*ROOT_URL=\K\S+' "$compose" 2>/dev/null | head -1)
|
|
url="${url%%#*}"
|
|
printf '%s' "${url%/}"
|
|
}
|
|
|
|
# Echoes "<userId> <authToken>" on success. Both are needed: Rocket.Chat wants
|
|
# them as separate X-User-Id / X-Auth-Token headers on every subsequent call.
|
|
_rocketchatLogin() {
|
|
local base="$1"
|
|
local user="${CFG_ROCKETCHAT_ADMIN_USERNAME:-admin}"
|
|
local pass="${CFG_ROCKETCHAT_ADMIN_PASSWORD_1}"
|
|
|
|
if [[ -z "$pass" || "$pass" == RANDOMIZEDPASSWORD* ]]; then
|
|
isError "No Rocket.Chat admin password in rocketchat.config — cannot authenticate."
|
|
return 1
|
|
fi
|
|
|
|
local res
|
|
res=$(curl -sS -m 20 -X POST "$base/api/v1/login" \
|
|
-H 'Content-Type: application/json' \
|
|
--data-binary "$(printf '{"user":%s,"password":%s}' \
|
|
"$(_rcJson "$user")" "$(_rcJson "$pass")")" 2>&1)
|
|
|
|
local id token
|
|
id=$(printf '%s' "$res" | python3 -c "import sys,json;d=json.load(sys.stdin);print(d.get('data',{}).get('userId',''))" 2>/dev/null)
|
|
token=$(printf '%s' "$res" | python3 -c "import sys,json;d=json.load(sys.stdin);print(d.get('data',{}).get('authToken',''))" 2>/dev/null)
|
|
|
|
if [[ -z "$id" || -z "$token" ]]; then
|
|
isError "Rocket.Chat admin login failed. Check CFG_ROCKETCHAT_ADMIN_USERNAME / _PASSWORD_1."
|
|
return 1
|
|
fi
|
|
printf '%s %s' "$id" "$token"
|
|
}
|
|
|
|
# Rocket.Chat enables a password policy by default that demands lower, upper,
|
|
# digit AND special, at 14+ characters. generateRandomPassword is alphanumeric,
|
|
# so a generated password is rejected by users.update with "does not meet the
|
|
# server's password policy" — note that users.create does NOT enforce it, which
|
|
# is why creating worked while resetting did not.
|
|
#
|
|
# Appending one character from each class guarantees compliance without
|
|
# weakening anything: the entropy of the generated part is untouched.
|
|
_rocketchatPassword() {
|
|
printf '%s%s' "$(generateRandomPassword)" 'aZ7#'
|
|
}
|
|
|
|
# JSON-encode a bash string so a password containing quotes or backslashes
|
|
# cannot break the request body.
|
|
_rcJson() {
|
|
printf '%s' "$1" | python3 -c "import sys,json;print(json.dumps(sys.stdin.read()))"
|
|
}
|
|
|
|
# _rocketchatApi <METHOD> <path> [json body]
|
|
_rocketchatApi() {
|
|
local method="$1" path="$2" body="$3"
|
|
local base; base=$(_rocketchatBaseUrl)
|
|
[[ -z "$base" ]] && { isError "Could not read Rocket.Chat's ROOT_URL from its compose file."; return 1; }
|
|
|
|
local creds; creds=$(_rocketchatLogin "$base") || return 1
|
|
local id="${creds%% *}" token="${creds##* }"
|
|
|
|
if [[ -n "$body" ]]; then
|
|
curl -sS -m 30 -X "$method" "$base$path" \
|
|
-H "X-User-Id: $id" -H "X-Auth-Token: $token" \
|
|
-H 'Content-Type: application/json' --data-binary "$body"
|
|
else
|
|
curl -sS -m 30 -X "$method" "$base$path" \
|
|
-H "X-User-Id: $id" -H "X-Auth-Token: $token"
|
|
fi
|
|
}
|
|
|
|
# Rocket.Chat answers 200 with {"success":false,"error":"..."} rather than an
|
|
# HTTP error, so success has to be read out of the body.
|
|
_rocketchatOk() {
|
|
printf '%s' "$1" | python3 -c "import sys,json
|
|
try: print('yes' if json.load(sys.stdin).get('success') else 'no')
|
|
except Exception: print('no')" 2>/dev/null
|
|
}
|
|
|
|
_rocketchatError() {
|
|
printf '%s' "$1" | python3 -c "import sys,json
|
|
try:
|
|
d=json.load(sys.stdin); print(d.get('error') or d.get('message') or 'unknown error')
|
|
except Exception: print('could not parse the API response')" 2>/dev/null
|
|
}
|
|
|
|
authAdapter_rocketchat_createUser() {
|
|
local email="$1" password="$2" username="$3" isAdmin="$4"
|
|
[[ -z "$email" ]] && { isError "An email address is required."; return 1; }
|
|
[[ -z "$username" ]] && username="${email%@*}"
|
|
[[ -z "$password" ]] && password=$(_rocketchatPassword)
|
|
|
|
local roles='["user"]'
|
|
[[ "$isAdmin" == "true" ]] && roles='["admin","user"]'
|
|
|
|
local body out
|
|
body=$(printf '{"email":%s,"name":%s,"password":%s,"username":%s,"roles":%s,"joinDefaultChannels":true,"requirePasswordChange":false,"verified":true}' \
|
|
"$(_rcJson "$email")" "$(_rcJson "$username")" "$(_rcJson "$password")" "$(_rcJson "$username")" "$roles")
|
|
out=$(_rocketchatApi POST /api/v1/users.create "$body") || return 1
|
|
|
|
[[ "$(_rocketchatOk "$out")" != "yes" ]] && { isError "Creating $email failed: $(_rocketchatError "$out")"; return 1; }
|
|
isSuccessful "Rocket.Chat user created — Email: $email — Username: $username — Password: $password"
|
|
}
|
|
|
|
# Resolve a username or email to Rocket.Chat's internal user id, which is what
|
|
# every mutating endpoint wants.
|
|
_rocketchatUserId() {
|
|
local who="$1" field="username"
|
|
[[ "$who" == *@* ]] && field="email"
|
|
local out
|
|
out=$(_rocketchatApi GET "/api/v1/users.info?${field}=${who}") || return 1
|
|
printf '%s' "$out" | python3 -c "import sys,json
|
|
try: print(json.load(sys.stdin).get('user',{}).get('_id',''))
|
|
except Exception: print('')" 2>/dev/null
|
|
}
|
|
|
|
_rocketchatUsernameOf() {
|
|
local who="$1" field="username"
|
|
[[ "$who" == *@* ]] && field="email"
|
|
local out
|
|
out=$(_rocketchatApi GET "/api/v1/users.info?${field}=${who}") || return 1
|
|
printf '%s' "$out" | python3 -c "import sys,json
|
|
try: print(json.load(sys.stdin).get('user',{}).get('username',''))
|
|
except Exception: print('')" 2>/dev/null
|
|
}
|
|
|
|
authAdapter_rocketchat_setPassword() {
|
|
local who="$1" password="$2"
|
|
[[ -z "$who" ]] && { isError "A username or email is required."; return 1; }
|
|
[[ -z "$password" ]] && password=$(_rocketchatPassword)
|
|
|
|
local uid; uid=$(_rocketchatUserId "$who") || return 1
|
|
[[ -z "$uid" ]] && { isError "No Rocket.Chat user '$who'."; return 1; }
|
|
|
|
local body out
|
|
body=$(printf '{"userId":%s,"data":{"password":%s,"requirePasswordChange":false}}' \
|
|
"$(_rcJson "$uid")" "$(_rcJson "$password")")
|
|
out=$(_rocketchatApi POST /api/v1/users.update "$body") || return 1
|
|
|
|
[[ "$(_rocketchatOk "$out")" != "yes" ]] && { isError "Resetting $who failed: $(_rocketchatError "$out")"; return 1; }
|
|
|
|
# If this is the account the tools authenticate as, the config has to follow
|
|
# or every later tool call fails to log in.
|
|
if [[ "$who" == "${CFG_ROCKETCHAT_ADMIN_USERNAME:-admin}" || "$who" == "${CFG_ROCKETCHAT_ADMIN_EMAIL:-}" ]]; then
|
|
authPersistCfg rocketchat ADMIN_PASSWORD "$password"
|
|
fi
|
|
|
|
isSuccessful "Rocket.Chat password set for $who — New password: $password"
|
|
}
|
|
|
|
authAdapter_rocketchat_listUsers() {
|
|
local out
|
|
out=$(_rocketchatApi GET '/api/v1/users.list?count=500') || return 1
|
|
[[ "$(_rocketchatOk "$out")" != "yes" ]] && { isError "Listing users failed: $(_rocketchatError "$out")"; return 1; }
|
|
|
|
local rendered
|
|
rendered=$(printf '%s' "$out" | python3 -c "
|
|
import sys, json
|
|
d = json.load(sys.stdin)
|
|
users = d.get('users', [])
|
|
for u in users:
|
|
roles = ','.join(u.get('roles') or []) or 'user'
|
|
state = '' if u.get('active', True) else ' (deactivated)'
|
|
email = (u.get('emails') or [{}])[0].get('address', '-')
|
|
print(' %-22s %-30s %s%s' % (u.get('username', '-'), email, roles, state))
|
|
print('LP_TOTAL:%d' % d.get('total', len(users)))
|
|
" 2>/dev/null)
|
|
|
|
local line total=0
|
|
while IFS= read -r line; do
|
|
case "$line" in
|
|
LP_TOTAL:*) total="${line#LP_TOTAL:}" ;;
|
|
*) [[ -n "$line" ]] && printf '%s\n' "$line" ;;
|
|
esac
|
|
done <<< "$rendered"
|
|
isSuccessful "$total Rocket.Chat account(s)."
|
|
}
|
|
|
|
# Deactivate, not delete. users.delete purges the account and its messages with
|
|
# no undo; setting active=false revokes access and is reversible from the admin
|
|
# UI, which is the safer default behind a single button.
|
|
authAdapter_rocketchat_deleteUser() {
|
|
local who="$1"
|
|
[[ -z "$who" ]] && { isError "A username or email is required."; return 1; }
|
|
|
|
if [[ "$who" == "${CFG_ROCKETCHAT_ADMIN_USERNAME:-admin}" || "$who" == "${CFG_ROCKETCHAT_ADMIN_EMAIL:-}" ]]; then
|
|
isError "Refusing to deactivate '$who' — it is the admin these tools authenticate as."
|
|
return 1
|
|
fi
|
|
|
|
local uid; uid=$(_rocketchatUserId "$who") || return 1
|
|
[[ -z "$uid" ]] && { isError "No Rocket.Chat user '$who'."; return 1; }
|
|
|
|
local out
|
|
out=$(_rocketchatApi POST /api/v1/users.setActiveStatus "$(printf '{"userId":%s,"activeStatus":false}' "$(_rcJson "$uid")")") || return 1
|
|
[[ "$(_rocketchatOk "$out")" != "yes" ]] && { isError "Deactivating $who failed: $(_rocketchatError "$out")"; return 1; }
|
|
isSuccessful "Rocket.Chat user '$who' deactivated. Re-enable them from Admin → Users."
|
|
}
|
|
|
|
# The counterpart to deleteUser. Deactivation is only a safe default if undoing
|
|
# it is equally easy — otherwise the "reversible" claim is theoretical.
|
|
authAdapter_rocketchat_enableUser() {
|
|
local who="$1"
|
|
[[ -z "$who" ]] && { isError "A username or email is required."; return 1; }
|
|
|
|
local uid; uid=$(_rocketchatUserId "$who") || return 1
|
|
[[ -z "$uid" ]] && { isError "No Rocket.Chat user '$who'."; return 1; }
|
|
|
|
local out
|
|
out=$(_rocketchatApi POST /api/v1/users.setActiveStatus "$(printf '{"userId":%s,"activeStatus":true}' "$(_rcJson "$uid")")") || return 1
|
|
[[ "$(_rocketchatOk "$out")" != "yes" ]] && { isError "Enabling $who failed: $(_rocketchatError "$out")"; return 1; }
|
|
isSuccessful "Rocket.Chat user '$who' re-enabled."
|
|
}
|
|
|
|
authAdapter_rocketchat_setAdmin() {
|
|
local who="$1" isAdmin="$2"
|
|
[[ -z "$who" ]] && { isError "A username or email is required."; return 1; }
|
|
local target="false"; [[ "$isAdmin" == "true" ]] && target="true"
|
|
|
|
if [[ "$target" == "false" && ( "$who" == "${CFG_ROCKETCHAT_ADMIN_USERNAME:-admin}" || "$who" == "${CFG_ROCKETCHAT_ADMIN_EMAIL:-}" ) ]]; then
|
|
isError "Refusing to demote '$who' — it is the admin these tools authenticate as."
|
|
return 1
|
|
fi
|
|
|
|
# The endpoint takes roleId + username, and nothing else: passing roleName
|
|
# fails schema validation with "must NOT have additional properties", and
|
|
# roleId + userId is rejected for a missing username. For built-in roles the
|
|
# id and the name happen to be the same string ("admin").
|
|
#
|
|
# username is resolved from the account rather than assumed from the input,
|
|
# so passing an email works here too.
|
|
local username
|
|
username=$(_rocketchatUsernameOf "$who") || return 1
|
|
[[ -z "$username" ]] && { isError "No Rocket.Chat user '$who'."; return 1; }
|
|
|
|
local endpoint="/api/v1/roles.addUserToRole"
|
|
[[ "$target" == "false" ]] && endpoint="/api/v1/roles.removeUserFromRole"
|
|
local body
|
|
body=$(printf '{"roleId":"admin","username":%s}' "$(_rcJson "$username")")
|
|
|
|
local out
|
|
out=$(_rocketchatApi POST "$endpoint" "$body") || return 1
|
|
[[ "$(_rocketchatOk "$out")" != "yes" ]] && { isError "Changing admin status for $who failed: $(_rocketchatError "$out")"; return 1; }
|
|
isSuccessful "Rocket.Chat user '$who' admin → $target."
|
|
}
|