#!/usr/bin/env python3 """대시보드 관리자 명단을 한 표로 봅니다 — 멈춘 초대·1인 의존·2단계 인증 (운영 매뉴얼 M20). 🟢 **읽기만 합니다.** 이 도구에는 지우거나 초대하는 기능이 아예 없습니다. 표준 라이브러리만 씁니다 — 설치할 것이 없습니다. export CF_API_TOKEN=<토큰> # 권한: Account Settings Read (계정 · 읽기) export CF_ACCOUNT_ID=<계정 ID> python members.py python members.py --json 다른 도구에 넘기실 때 무엇을 찾아 주나 — 화면에서는 한 사람씩 펼쳐야 보이는 것 셋입니다. 1) **수락되지 않은 초대**. 초대 메일은 만료됩니다. 화면의 「Invite Pending」 은 사람마다 줄을 펼쳐야 보이므로, 여러 명을 초대한 뒤에는 누가 안 들어왔는지 한눈에 세기 어렵습니다. 2) **Super Administrator 가 몇 명인가**. 관리자를 초대할 수 있는 사람은 Super Administrator 뿐입니다. 한 명이면 그 한 사람이 2단계 인증에서 잠기는 날 **초대도 권한 변경도 그날 안에 못 합니다.** 3) **2단계 인증이 꺼진 관리자**. 계정 API 가 사람마다 `two_factor_authentication_enabled` 를 알려 줍니다. 🔴 **이 도구가 답하지 못하는 것 — 초대가 언제 만료되는지.** 공식 문서는 「만료된다」와 「다시 보낼 수 있다」만 적고 **기간을 적지 않습니다.** 그래서 남은 날짜를 계산해 보여 주지 않습니다 — 지어낸 숫자를 운영 기준으로 쓰게 되기 때문입니다. 판단은 「수락 안 됨」이 보이면 다시 보내는 것으로 충분합니다. 🔵 **초대 대신 바로 넣는 길이 있습니다(Enterprise).** 이미 Cloudflare 계정이 있는 사람이면 메일 확인 단계를 건너뛰고 바로 추가할 수 있습니다(화면의 Direct Add). 초대 메일이 계속 만료되는 상황이라면 이 길이 더 빠릅니다. ⚠️ **대시보드 관리자와 Zero Trust 사용자는 다른 명단입니다.** 여기 나오는 사람은 설정을 바꾸는 관리자이고, 앱에 접속하는 직원은 좌석(seats) 쪽입니다. 좌석은 remove.py · inactive.py 를 보십시오. 공식 문서 멤버 관리 https://developers.cloudflare.com/fundamentals/manage-members/manage/ 멤버 목록 API https://developers.cloudflare.com/api/resources/accounts/subresources/members/methods/list/ 역할 목록 API https://developers.cloudflare.com/api/resources/accounts/subresources/roles/methods/list/ """ import argparse import json import os import sys import urllib.error import urllib.parse import urllib.request API = "https://api.cloudflare.com/client/v4" TOKEN = os.environ.get("CF_API_TOKEN", "") ACCOUNT = os.environ.get("CF_ACCOUNT_ID", "") if not TOKEN or not ACCOUNT: sys.exit("CF_API_TOKEN 과 CF_ACCOUNT_ID 를 환경변수로 넣어 주세요.") def call(path, params=None): url = API + "/accounts/" + ACCOUNT + path if params: url += "?" + urllib.parse.urlencode(params) req = urllib.request.Request(url, method="GET") req.add_header("Authorization", "Bearer " + TOKEN) try: with urllib.request.urlopen(req, timeout=30) as r: return json.loads(r.read().decode("utf-8")) except urllib.error.HTTPError as e: raw = e.read().decode("utf-8", "replace") try: msg = json.dumps(json.loads(raw).get("errors"), ensure_ascii=False) except ValueError: msg = raw[:200] sys.exit("GET %s → HTTP %d %s" % (path, e.code, msg)) def members(): """멤버 전부. 🔴 첫 장만 읽으면 조용히 일부만 봅니다 — total_pages 까지 따라갑니다.""" out, page = [], 1 while True: d = call("/members", {"page": page, "per_page": 50}) out.extend(d.get("result") or []) info = d.get("result_info") or {} total = info.get("total_pages") or 1 if page >= total: return out page += 1 def role_names(m): """역할 이름. 예전 방식은 roles[], 범위를 지정한 방식은 policies[] 에 들어 있습니다. 🔴 **두 곳에 같은 이름이 같이 들어 있습니다**(실제 계정에서 확인). 그대로 이으면 같은 역할이 두 번 찍혀 「권한이 두 배로 있는 것처럼」 보입니다 — 그래서 중복을 걷어냅니다. """ names = [r.get("name", "") for r in (m.get("roles") or []) if r.get("name")] for p in m.get("policies") or []: for g in p.get("permission_groups") or []: if g.get("name"): names.append(g["name"]) seen, out = set(), [] for n in names: if n not in seen: seen.add(n) out.append(n) return out def role_summary(names): """역할이 100개를 넘는 멤버가 실제로 있습니다 — 줄을 잘라 버리면 정작 중요한 Super Administrator 가 안 보입니다. 그래서 최고 권한을 앞으로 끌고 개수를 함께 냅니다.""" if not names: return "(역할 없음)" first = [n for n in names if "super administrator" in n.lower()] or names[:1] rest = len(names) - 1 return first[0][:44] + (" 외 %d개" % rest if rest > 0 else "") def is_super(m): return any("super administrator" in n.lower() for n in role_names(m)) def main(): ap = argparse.ArgumentParser(description="대시보드 관리자 명단 점검 (읽기만)") ap.add_argument("--json", action="store_true", help="사람이 읽는 표 대신 JSON") a = ap.parse_args() rows = [] for m in members(): user = m.get("user") or {} # 🔴 status 의 글자를 단정하지 않습니다. 「accepted 가 아니면 아직 안 들어온 것」 # 으로만 봅니다 — 문자열이 바뀌어도 판정이 틀리지 않습니다. status = m.get("status") or "?" rows.append({ "email": m.get("email") or user.get("email") or "?", "status": status, "accepted": status == "accepted", "super": is_super(m), "two_factor": bool(user.get("two_factor_authentication_enabled")), "api_access_enabled": bool(m.get("api_access_enabled")), "roles": role_names(m), "member_id": m.get("id"), }) rows.sort(key=lambda r: (r["accepted"], not r["super"], r["email"])) pending = [r for r in rows if not r["accepted"]] supers = [r for r in rows if r["super"]] super_no_2fa = [r for r in supers if not r["two_factor"]] if a.json: print(json.dumps({ "members": rows, "pending": [r["email"] for r in pending], "super_admins": [r["email"] for r in supers], "super_admins_without_2fa": [r["email"] for r in super_no_2fa], }, ensure_ascii=False, indent=2)) return print("대시보드 멤버 %d명" % len(rows)) print("%-38s %-12s %-5s %-5s %s" % ("이메일", "상태", "최고", "2단계", "역할")) for r in rows: print("%-38s %-12s %-5s %-5s %s" % ( r["email"], "수락" if r["accepted"] else "수락 안 됨", "예" if r["super"] else "", "켜짐" if r["two_factor"] else "꺼짐", role_summary(r["roles"]), )) print("") if pending: print("🔴 수락되지 않은 초대 %d건 — 다시 보내 주십시오(화면: Members › 해당 줄 › Resend Invite)." % len(pending)) for r in pending: print(" · " + r["email"]) else: print("🟢 멈춰 있는 초대는 없습니다.") if len(supers) == 0: print("⚠️ Super Administrator 를 찾지 못했습니다 — 토큰 권한이 좁아 역할이 안 보일 수 있습니다.") elif len(supers) == 1: print("🔴 Super Administrator 가 1명(%s)뿐입니다. 그 한 분이 2단계 인증에서 잠기면" % supers[0]["email"]) print(" 초대·권한 변경이 그날 안에 안 됩니다. 2명 이상을 권합니다.") else: print("🟢 Super Administrator %d명 — 한 사람이 잠겨도 나머지가 풀 수 있습니다." % len(supers)) if super_no_2fa: print("🟠 2단계 인증이 꺼진 Super Administrator %d명 — 켜는 것을 권합니다." % len(super_no_2fa)) for r in super_no_2fa: print(" · " + r["email"]) if __name__ == "__main__": main()