#!/usr/bin/env python3 """자동 정리가 **누구를** 데려가는지 미리 봅니다 (운영 매뉴얼 M7). 🟢 읽기만 합니다. 아무것도 바꾸지 않습니다. 표준 라이브러리만 씁니다 — 설치할 것이 없습니다. export CF_API_TOKEN=<읽기 전용 토큰> # 권한: Access Read export CF_ACCOUNT_ID=<계정 ID> python inactive.py # 조직에 설정된 기간으로 셉니다 python inactive.py --days 90 # 「90일로 바꾸면 누가 걸리나」를 미리 봅니다 무엇을 하나 — Zero Trust 에서 자동으로 도는 정리는 **사용자 단위 하나뿐**입니다 (Settings › Admin controls › Remove inactive users from seats · 1개월~1년). 이 스크립트는 그 설정값을 읽고, **지금 그 기준에 걸리는 사람**을 줄로 보여 줍니다. 🔴 **활성 사용자에게 쌓인 옛 단말은 이 설정으로 안 지워집니다.** 정리는 「사람」 단위라서, 매일 들어오는 사람의 오래된 기기 줄은 그대로 남습니다 — 그건 M4 의 경로로 쌓인 것이고 기기 쪽 스크립트(hidden.py · cleanup.py)로 골라내야 합니다. 🔴 **좌석에서 빼는 것이 차단은 아닙니다.** 명단에 남아 있으면 다시 로그인해 다시 차지합니다(M6). 공식 문서 좌석 관리 https://developers.cloudflare.com/cloudflare-one/team-and-resources/users/seat-management/ 조직 조회 https://developers.cloudflare.com/api/resources/zero_trust/subresources/organizations/methods/list/ 사용자 목록 https://developers.cloudflare.com/api/resources/zero_trust/subresources/access/subresources/users/methods/list/ """ import argparse import datetime 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) 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 get_all(path): out, cursor = [], "" for _ in range(50): q = {"per_page": "100"} if cursor: q["cursor"] = cursor j = call(path, 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 parse_when(s): """ISO 시각을 날짜로. 못 읽으면 None — **못 읽은 줄은 대상에 넣지 않습니다.**""" if not s: return None t = str(s).replace("Z", "+00:00") try: return datetime.datetime.fromisoformat(t) except ValueError: return None ap = argparse.ArgumentParser(description="자동 정리에 걸릴 사람을 미리 봅니다 (M7)") ap.add_argument("--days", type=int, default=0, help="기준 일수를 직접 넣어 봅니다(조직 설정 대신)") a = ap.parse_args() org = (call("/access/organizations") or {}).get("result") or {} # 조직 설정값은 **분 단위**로 옵니다(user_seat_expiration_inactive_time 은 예: "43200m"). raw = str(org.get("user_seat_expiration_inactive_time") or "") set_days = 0 if raw.endswith("m") and raw[:-1].isdigit(): set_days = int(raw[:-1]) // (60 * 24) elif raw.endswith("h") and raw[:-1].isdigit(): set_days = int(raw[:-1]) // 24 elif raw.isdigit(): set_days = int(raw) // (60 * 24) days = a.days or set_days print("조직 설정 user_seat_expiration_inactive_time : %s%s" % (raw or "(꺼져 있습니다)", "" if not set_days else " (약 %d일)" % set_days)) if a.days: print("이 실행에서 쓰는 기준 : %d일 (직접 주신 값)" % days) if not days: print("\n🔴 기준이 없습니다. 조직에 설정이 없으면 **자동 정리가 아예 돌지 않습니다** —") print(" Settings › Admin controls › Remove inactive users from seats 에서 켭니다.") print(" 지금 누가 걸릴지 보려면 --days 90 처럼 직접 주십시오.") sys.exit(0) now = datetime.datetime.now(datetime.timezone.utc) users = get_all("/access/users") old, unknown, seated = [], [], 0 for u in users: if u.get("access_seat") or u.get("gateway_seat"): seated += 1 when = parse_when(u.get("last_successful_login")) if when is None: unknown.append(u) continue idle = (now - when).days if idle >= days: old.append((idle, u)) old.sort(key=lambda x: -x[0]) print("\n사용자 %d명 · 좌석을 쓰는 사람 %d명" % (len(users), seated)) print("기준(%d일) 이상 로그인 없는 사람 : %d명" % (days, len(old))) print("%-34s %-10s %s" % ("이메일", "안 들어온 날", "좌석")) print("-" * 60) for idle, u in old[:60]: print("%-34s %-10s %s" % ( str(u.get("email") or "-")[:34], "%d일" % idle, "쓰는 중" if (u.get("access_seat") or u.get("gateway_seat")) else "없음")) if unknown: print("\n마지막 로그인을 못 읽은 줄 %d개 — **대상에 넣지 않았습니다.**" % len(unknown)) print("한 번도 로그인하지 않은 사람일 수 있습니다(좌석도 없습니다).") print("\n🔴 이 목록은 **사람**입니다. 활성 사용자에게 쌓인 옛 기기 줄은 여기 안 나옵니다 —") print(" 그건 hidden.py / cleanup.py 로 봅니다(M4).")