#!/usr/bin/env python3 """화면의 「Remove users」 와 API 의 사용자 삭제는 무엇이 다른가 (운영 매뉴얼 M6). 🟢 기본은 읽기만 합니다. 바꾸는 것은 --release-seat / --delete-user 를 --apply 와 함께 줄 때뿐이고, 그때도 한 사람씩만 합니다. 표준 라이브러리만 씁니다 — 설치할 것이 없습니다. export CF_API_TOKEN=<토큰> # 읽기만 할 때: Access Read · 바꿀 때: Access Write export CF_ACCOUNT_ID=<계정 ID> # Zero Trust 화면 주소의 32자리 python remove.py --email someone@example.com # 지금 상태만 봅니다 python remove.py --email someone@example.com --release-seat --apply python remove.py --email someone@example.com --delete-user --apply --yes 왜 두 동작이 헷갈리나 — 이름이 비슷한데 **바뀌는 것이 다릅니다.** 화면의 「Remove users」 → PATCH /access/seats 좌석만 놓습니다. 사용자 줄은 남습니다. API 의 사용자 삭제 → DELETE /access/users/{id} 화면에 대응 버튼이 없는 동작입니다. 🔴 **어느 쪽도 지금 열려 있는 세션을 끊지 않습니다.** 이 스크립트는 그것을 말로만 하지 않고 `active_sessions` 를 전후로 세어 보여 줍니다 — 「지웠는데 왜 화면이 그대로 열려 있나」의 답입니다. 🔴 **영구 차단은 Cloudflare 만으로 되지 않습니다.** 명단(정책)에 이메일이 남아 있으면 다시 로그인해서 좌석을 다시 차지합니다. IdP 계정 비활성화를 함께 해야 끝납니다. 공식 문서 좌석 관리 https://developers.cloudflare.com/cloudflare-one/team-and-resources/users/seat-management/ 좌석 PATCH https://developers.cloudflare.com/api/resources/zero_trust/subresources/seats/methods/edit/ 사용자 목록 https://developers.cloudflare.com/api/resources/zero_trust/subresources/access/subresources/users/methods/list/ 사용자 삭제 https://developers.cloudflare.com/api/resources/zero_trust/subresources/access/subresources/users/methods/delete/ 열린 세션 https://developers.cloudflare.com/api/resources/zero_trust/subresources/access/subresources/users/subresources/active_sessions/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(method, path, body=None, params=None): """한 번 호출. 실패하면 **조용히 넘기지 않고** 그 자리에서 보여 줍니다.""" url = API + "/accounts/" + ACCOUNT + path if params: url += "?" + urllib.parse.urlencode(params) data = None if body is None else json.dumps(body).encode("utf-8") req = urllib.request.Request(url, data=data, method=method) req.add_header("Authorization", "Bearer " + TOKEN) if data: req.add_header("Content-Type", "application/json") 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: j = json.loads(raw) msg = json.dumps(j.get("errors"), ensure_ascii=False) except ValueError: msg = raw[:300] sys.exit("%s %s → HTTP %d %s" % (method, path, e.code, msg)) def get_all(path, params=None): """커서 페이징. 🔴 page= 는 2750 not supported 로 죽습니다 — 커서만 씁니다.""" out, cursor = [], "" for _ in range(50): q = dict(params or {}) q["per_page"] = "100" if cursor: q["cursor"] = cursor j = call("GET", path, params=q) batch = j.get("result") or [] out.extend(batch) nxt = (j.get("result_info") or {}).get("cursor") or "" if not nxt or nxt == cursor or not batch: break cursor = nxt return out def find_user(email): want = email.strip().lower() for u in get_all("/access/users"): if str(u.get("email") or "").strip().lower() == want: return u return None def sessions(user_id): """열린 세션 수. 권한이 없거나 기능이 꺼져 있으면 세지 못한 것으로 둡니다.""" try: j = call("GET", "/access/users/%s/active_sessions" % user_id) return len(j.get("result") or []) except SystemExit: return None def show(label, u): seat = u.get("access_seat") gw = u.get("gateway_seat") n = sessions(u.get("id")) print(" %-6s 좌석(access) %-5s · 좌석(gateway) %-5s · 열린 세션 %s" % (label, "예" if seat else "아니오", "예" if gw else "아니오", "못 셈(권한)" if n is None else "%d개" % n)) def main(): ap = argparse.ArgumentParser(description="좌석 반납과 사용자 삭제를 구분해 보여 줍니다 (M6)") ap.add_argument("--email", required=True, help="대상 한 사람") ap.add_argument("--release-seat", action="store_true", help="화면의 「Remove users」 와 같은 요청(PATCH seats)") ap.add_argument("--delete-user", action="store_true", help="화면에 없는 API 전용 동작(DELETE users)") ap.add_argument("--apply", action="store_true", help="이것이 없으면 아무것도 바꾸지 않습니다") ap.add_argument("--yes", action="store_true", help="--delete-user 에 함께 필요합니다") a = ap.parse_args() u = find_user(a.email) if not u: sys.exit("그 주소는 이 계정의 Zero Trust 사용자 목록에 없습니다: " + a.email) print("사용자 %s" % u.get("email")) print(" id %s" % u.get("id")) print(" seat_uid %s" % (u.get("seat_uid") or "-")) print(" 마지막 로그인 %s" % (u.get("last_successful_login") or "-")) print("\n[전]") show("지금", u) if not (a.release_seat or a.delete_user): print("\n무엇도 바꾸지 않았습니다. 바꾸려면 --release-seat 또는 --delete-user 를 --apply 와 함께 주십시오.") print("두 동작의 차이:") print(" --release-seat PATCH /access/seats 좌석만 놓습니다. 사용자 줄과 세션은 남습니다.") print(" --delete-user DELETE /access/users/{id} 화면에 대응 버튼이 없는 동작입니다.") return if not a.apply: print("\n미리보기입니다(--apply 없음). 보낼 요청:") if a.release_seat: print(" PATCH /accounts/%s/access/seats" % ACCOUNT) print(" [{\"seat_uid\": \"%s\", \"access_seat\": false, \"gateway_seat\": false}]" % (u.get("seat_uid") or "?")) if a.delete_user: print(" DELETE /accounts/%s/access/users/%s" % (ACCOUNT, u.get("id"))) return if a.delete_user and not a.yes: sys.exit("--delete-user 는 --yes 도 필요합니다. 되돌릴 수 없는 동작입니다.") if a.release_seat: if not u.get("seat_uid"): sys.exit("seat_uid 가 없습니다 — 좌석을 쓰고 있지 않아 반납할 것이 없습니다.") call("PATCH", "/access/seats", body=[{ "seat_uid": u.get("seat_uid"), "access_seat": False, "gateway_seat": False, }]) print("\n좌석을 반납했습니다(화면의 「Remove users」 와 같은 요청).") if a.delete_user: call("DELETE", "/access/users/%s" % u.get("id")) print("\n사용자를 삭제했습니다(API 전용 동작).") after = find_user(a.email) print("\n[후]") if after: show("지금", after) print("\n🔴 사용자 줄이 **그대로 남아 있습니다.** 좌석을 놓은 것과 사람을 지운 것은 다른 일입니다.") else: print(" 사용자 목록에서 사라졌습니다.") print("🔴 열린 세션은 어느 쪽으로도 끊기지 않습니다 — 만료까지 그 브라우저는 계속 열립니다.") print("🔴 명단(Access 정책)에 이메일이 남아 있으면 다시 로그인해 좌석을 다시 차지합니다.") print(" 영구 차단은 IdP 계정 비활성화까지 해야 끝납니다.") if __name__ == "__main__": main()