net-kingdom/sso-mfa/k8s/lldap/manage-group-members.sh
tegwick a9aec541ec
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 4s
Implement NK-WP-0021 activity-core ops SSO least-privilege.
Seed LLDAP activity-core-operators, add membership runbook and helper,
restrict Authelia access on activity/temporal.coulomb.social to that
group (member one_factor + domain deny fallback), apply live, and verify
via Authelia check-policy plus unauthenticated edge redirects.
2026-07-22 15:47:26 +02:00

179 lines
5.4 KiB
Bash
Executable file

#!/usr/bin/env bash
# manage-group-members.sh — add/remove/list LLDAP group membership (NK-WP-0021)
#
# Usage:
# ./manage-group-members.sh add <uid> <group> [lldap-url] [secrets-dir]
# ./manage-group-members.sh remove <uid> <group> [lldap-url] [secrets-dir]
# ./manage-group-members.sh list <group> [lldap-url] [secrets-dir]
#
# Auth: LLDAP_LDAP_USER_PASS from secrets-dir/lldap/secrets.env, or env
# LLDAP_ADMIN_PASS (e.g. from kubectl secret for live ops).
#
# Examples:
# ./manage-group-members.sh add bernd activity-core-operators
# ./manage-group-members.sh list activity-core-operators
set -euo pipefail
ACTION="${1:-}"
shift || true
LLDAP_URL="https://lldap.coulomb.social"
SECRETS_DIR="../../bootstrap/secrets"
usage() {
sed -n '2,15p' "$0" | sed 's/^# \?//'
exit 1
}
case "$ACTION" in
add|remove)
USERNAME="${1:-}"
GROUP_NAME="${2:-}"
[[ -z "$USERNAME" || -z "$GROUP_NAME" ]] && usage
shift 2 || true
;;
list)
GROUP_NAME="${1:-}"
[[ -z "$GROUP_NAME" ]] && usage
shift 1 || true
USERNAME=""
;;
*)
usage
;;
esac
[[ -n "${1:-}" ]] && LLDAP_URL="$1"
[[ -n "${2:-}" ]] && SECRETS_DIR="$2"
LLDAP_ENV="$SECRETS_DIR/lldap/secrets.env"
LLDAP_ADMIN_PASS="${LLDAP_ADMIN_PASS:-}"
if [[ -z "$LLDAP_ADMIN_PASS" ]]; then
if [[ ! -f "$LLDAP_ENV" ]]; then
echo "ERROR: $LLDAP_ENV not found and LLDAP_ADMIN_PASS unset." >&2
exit 1
fi
LLDAP_ADMIN_PASS=$(bash -c "source '$LLDAP_ENV' 2>/dev/null; echo \"\${LLDAP_LDAP_USER_PASS:-}\"")
fi
if [[ -z "$LLDAP_ADMIN_PASS" ]]; then
echo "ERROR: empty LLDAP admin password" >&2
exit 1
fi
echo "Authenticating to LLDAP at $LLDAP_URL ..."
AUTH_RESP=$(curl -sS -X POST "$LLDAP_URL/auth/simple/login" \
-H "Content-Type: application/json" \
-d "{\"username\":\"admin\",\"password\":\"$LLDAP_ADMIN_PASS\"}")
LLDAP_TOKEN=$(echo "$AUTH_RESP" | python3 -c \
"import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null || echo "")
if [[ -z "$LLDAP_TOKEN" ]]; then
echo "ERROR: Authentication failed" >&2
exit 1
fi
# Build GraphQL POST body without shell-expanding $variables inside the query.
gql_post() {
local query_file="$1"
local vars_json="$2"
python3 - "$LLDAP_URL" "$LLDAP_TOKEN" "$query_file" "$vars_json" <<'PY'
import json, sys, urllib.request
url, token, qpath, vars_s = sys.argv[1:5]
query = open(qpath, encoding="utf-8").read()
body = json.dumps({"query": query, "variables": json.loads(vars_s)}).encode()
req = urllib.request.Request(
url.rstrip("/") + "/api/graphql",
data=body,
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=30) as resp:
print(resp.read().decode())
PY
}
TMPDIR_GQL=$(mktemp -d)
trap 'rm -rf "$TMPDIR_GQL"' EXIT
cat >"$TMPDIR_GQL/list.gql" <<'EOF'
query { groups { id displayName users { id displayName email } } }
EOF
GROUPS_JSON=$(gql_post "$TMPDIR_GQL/list.gql" '{}')
GROUP_ID=$(echo "$GROUPS_JSON" | python3 -c "
import sys, json
name = sys.argv[1]
d = json.load(sys.stdin)
for g in d.get('data', {}).get('groups', []) or []:
if g.get('displayName') == name:
print(g['id'])
break
" "$GROUP_NAME")
if [[ -z "$GROUP_ID" ]]; then
echo "ERROR: group '$GROUP_NAME' not found — run bootstrap-users.sh first" >&2
exit 1
fi
case "$ACTION" in
list)
echo "$GROUPS_JSON" | python3 -c "
import sys, json
name = sys.argv[1]
d = json.load(sys.stdin)
for g in d.get('data', {}).get('groups', []) or []:
if g.get('displayName') == name:
users = g.get('users') or []
print(f\"Group {name} (id={g.get('id')}): {len(users)} member(s)\")
for u in users:
print(f\" - {u.get('id')} {u.get('displayName') or ''} {u.get('email') or ''}\")
break
" "$GROUP_NAME"
;;
add)
cat >"$TMPDIR_GQL/add.gql" <<'EOF'
mutation AddToGroup($userId: String!, $groupId: Int!) {
addUserToGroup(userId: $userId, groupId: $groupId) { ok }
}
EOF
VARS=$(python3 -c "import json,sys; print(json.dumps({'userId':sys.argv[1],'groupId':int(sys.argv[2])}))" "$USERNAME" "$GROUP_ID")
RESP=$(gql_post "$TMPDIR_GQL/add.gql" "$VARS")
echo "$RESP" | python3 -c "
import sys, json
d = json.load(sys.stdin)
if d.get('errors'):
msg = d['errors'][0].get('message', str(d['errors']))
if 'UNIQUE constraint' in msg or 'already' in msg.lower():
print('OK: already a member (no-op)')
sys.exit(0)
print('ERROR:', msg, file=sys.stderr)
sys.exit(1)
print('OK: added user to group')
"
echo " user=$USERNAME group=$GROUP_NAME (id=$GROUP_ID)"
;;
remove)
cat >"$TMPDIR_GQL/remove.gql" <<'EOF'
mutation RemoveFromGroup($userId: String!, $groupId: Int!) {
removeUserFromGroup(userId: $userId, groupId: $groupId) { ok }
}
EOF
VARS=$(python3 -c "import json,sys; print(json.dumps({'userId':sys.argv[1],'groupId':int(sys.argv[2])}))" "$USERNAME" "$GROUP_ID")
RESP=$(gql_post "$TMPDIR_GQL/remove.gql" "$VARS")
echo "$RESP" | python3 -c "
import sys, json
d = json.load(sys.stdin)
if d.get('errors'):
print('ERROR:', d['errors'][0].get('message', d['errors']), file=sys.stderr)
sys.exit(1)
print('OK: removed user from group')
"
echo " user=$USERNAME group=$GROUP_NAME (id=$GROUP_ID)"
;;
esac