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.
179 lines
5.4 KiB
Bash
Executable file
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
|