Skip to content

Commit 36071fc

Browse files
Add inactive user deprovisioning tool
Refs SOU-1539
1 parent d739b58 commit 36071fc

3 files changed

Lines changed: 416 additions & 0 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,10 @@ dist
160160

161161
# End of https://www.toptal.com/developers/gitignore/api/yarn,node
162162

163+
# Python bytecode caches
164+
__pycache__/
165+
*.py[cod]
166+
163167
.claude
164168
.sourcebot
165169
/bin

tools/deprovisionUsers.py

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
#!/usr/bin/env python3
2+
"""Interactively deprovision inactive users from a Sourcebot organization."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import json
8+
import os
9+
import re
10+
import sys
11+
import urllib.error
12+
import urllib.parse
13+
import urllib.request
14+
from dataclasses import dataclass
15+
from datetime import datetime, timedelta, timezone
16+
from typing import Any, Sequence
17+
18+
19+
USERS_PATH = "/api/ee/users"
20+
USER_PATH = "/api/ee/user"
21+
DURATION_PATTERN = re.compile(r"^(?P<amount>\d+(?:\.\d+)?)(?P<unit>[smhdw])$", re.IGNORECASE)
22+
DURATION_UNITS = {
23+
"s": "seconds",
24+
"m": "minutes",
25+
"h": "hours",
26+
"d": "days",
27+
"w": "weeks",
28+
}
29+
30+
31+
@dataclass(frozen=True)
32+
class User:
33+
id: str
34+
name: str | None
35+
email: str
36+
role: str
37+
last_activity_at: datetime | None
38+
created_at: datetime
39+
suspended_at: datetime | None
40+
41+
@classmethod
42+
def from_json(cls, value: dict[str, Any]) -> "User":
43+
return cls(
44+
id=require_string(value, "id"),
45+
name=optional_string(value, "name"),
46+
email=require_string(value, "email"),
47+
role=require_string(value, "role"),
48+
last_activity_at=parse_optional_datetime(value, "lastActivityAt"),
49+
created_at=parse_datetime(require_string(value, "createdAt")),
50+
suspended_at=parse_optional_datetime(value, "suspendedAt"),
51+
)
52+
53+
54+
class SourcebotApiError(RuntimeError):
55+
pass
56+
57+
58+
class SourcebotClient:
59+
def __init__(self, base_url: str, api_key: str, timeout: float = 30.0) -> None:
60+
self.base_url = base_url.rstrip("/")
61+
self.api_key = api_key
62+
self.timeout = timeout
63+
64+
def list_users(self) -> list[User]:
65+
payload = self._request("GET", USERS_PATH)
66+
if not isinstance(payload, list):
67+
raise SourcebotApiError("The users endpoint returned an unexpected response.")
68+
return [User.from_json(value) for value in payload]
69+
70+
def deprovision_user(self, user_id: str) -> None:
71+
query = urllib.parse.urlencode({"userId": user_id})
72+
self._request("DELETE", f"{USER_PATH}?{query}")
73+
74+
def _request(self, method: str, path: str) -> Any:
75+
request = urllib.request.Request(
76+
f"{self.base_url}{path}",
77+
method=method,
78+
headers={
79+
"Accept": "application/json",
80+
"Authorization": f"Bearer {self.api_key}",
81+
"User-Agent": "sourcebot-user-deprovisioning/1.0",
82+
},
83+
)
84+
try:
85+
with urllib.request.urlopen(request, timeout=self.timeout) as response:
86+
body = response.read()
87+
except urllib.error.HTTPError as error:
88+
body = error.read().decode("utf-8", errors="replace")
89+
message = extract_error_message(body) or error.reason
90+
raise SourcebotApiError(f"Sourcebot returned HTTP {error.code}: {message}") from error
91+
except urllib.error.URLError as error:
92+
raise SourcebotApiError(f"Could not connect to Sourcebot: {error.reason}") from error
93+
94+
if not body:
95+
return None
96+
try:
97+
return json.loads(body)
98+
except json.JSONDecodeError as error:
99+
raise SourcebotApiError("Sourcebot returned invalid JSON.") from error
100+
101+
102+
def require_string(value: dict[str, Any], field: str) -> str:
103+
result = value.get(field)
104+
if not isinstance(result, str):
105+
raise SourcebotApiError(f"User response is missing string field {field!r}.")
106+
return result
107+
108+
109+
def optional_string(value: dict[str, Any], field: str) -> str | None:
110+
result = value.get(field)
111+
if result is not None and not isinstance(result, str):
112+
raise SourcebotApiError(f"User response has invalid field {field!r}.")
113+
return result
114+
115+
116+
def parse_optional_datetime(value: dict[str, Any], field: str) -> datetime | None:
117+
raw_value = value.get(field)
118+
if raw_value is None:
119+
return None
120+
if not isinstance(raw_value, str):
121+
raise SourcebotApiError(f"User response has invalid field {field!r}.")
122+
return parse_datetime(raw_value)
123+
124+
125+
def parse_datetime(value: str) -> datetime:
126+
try:
127+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
128+
except ValueError as error:
129+
raise SourcebotApiError(f"Sourcebot returned an invalid timestamp: {value!r}.") from error
130+
if parsed.tzinfo is None:
131+
raise SourcebotApiError(f"Sourcebot returned a timestamp without a timezone: {value!r}.")
132+
return parsed.astimezone(timezone.utc)
133+
134+
135+
def parse_duration(value: str) -> timedelta:
136+
match = DURATION_PATTERN.fullmatch(value.strip())
137+
if not match:
138+
raise argparse.ArgumentTypeError(
139+
"must be a number followed by s, m, h, d, or w (for example: 90d)"
140+
)
141+
amount = float(match.group("amount"))
142+
duration = timedelta(**{DURATION_UNITS[match.group("unit").lower()]: amount})
143+
if duration <= timedelta(0):
144+
raise argparse.ArgumentTypeError("must be greater than zero")
145+
return duration
146+
147+
148+
def parse_base_url(value: str) -> str:
149+
normalized = value.strip().rstrip("/")
150+
parsed = urllib.parse.urlsplit(normalized)
151+
if parsed.scheme not in ("http", "https") or not parsed.netloc:
152+
raise argparse.ArgumentTypeError("must be an absolute http:// or https:// URL")
153+
if parsed.query or parsed.fragment:
154+
raise argparse.ArgumentTypeError("must not contain a query string or fragment")
155+
return normalized
156+
157+
158+
def find_inactive_users(users: Sequence[User], cutoff: datetime) -> list[User]:
159+
candidates = []
160+
for user in users:
161+
if user.suspended_at is not None:
162+
continue
163+
activity_reference = user.last_activity_at or user.created_at
164+
if activity_reference < cutoff:
165+
candidates.append(user)
166+
return sorted(candidates, key=lambda user: user.last_activity_at or user.created_at)
167+
168+
169+
def format_duration(duration: timedelta) -> str:
170+
seconds = max(0, int(duration.total_seconds()))
171+
days, remainder = divmod(seconds, 86400)
172+
hours, remainder = divmod(remainder, 3600)
173+
minutes, _ = divmod(remainder, 60)
174+
if days:
175+
return f"{days}d {hours}h"
176+
if hours:
177+
return f"{hours}h {minutes}m"
178+
return f"{minutes}m"
179+
180+
181+
def print_users_table(users: Sequence[User], now: datetime) -> None:
182+
headers = ("Name", "Email", "Role", "Last activity", "Inactive for")
183+
rows = []
184+
for user in users:
185+
reference = user.last_activity_at or user.created_at
186+
rows.append(
187+
(
188+
user.name or "—",
189+
user.email,
190+
user.role,
191+
user.last_activity_at.isoformat(timespec="seconds") if user.last_activity_at else "Never",
192+
format_duration(now - reference),
193+
)
194+
)
195+
196+
widths = [max(len(header), *(len(row[index]) for row in rows)) for index, header in enumerate(headers)]
197+
separator = "+-" + "-+-".join("-" * width for width in widths) + "-+"
198+
199+
def print_row(row: Sequence[str]) -> None:
200+
print("| " + " | ".join(value.ljust(widths[index]) for index, value in enumerate(row)) + " |")
201+
202+
print(separator)
203+
print_row(headers)
204+
print(separator)
205+
for row in rows:
206+
print_row(row)
207+
print(separator)
208+
209+
210+
def extract_error_message(body: str) -> str | None:
211+
try:
212+
payload = json.loads(body)
213+
except json.JSONDecodeError:
214+
return body.strip() or None
215+
if isinstance(payload, dict):
216+
for key in ("message", "error"):
217+
if isinstance(payload.get(key), str):
218+
return payload[key]
219+
return body.strip() or None
220+
221+
222+
def build_parser() -> argparse.ArgumentParser:
223+
parser = argparse.ArgumentParser(
224+
description="Preview and deprovision inactive Sourcebot organization users."
225+
)
226+
parser.add_argument(
227+
"--base-url",
228+
required=True,
229+
type=parse_base_url,
230+
help="Sourcebot base URL, such as https://sourcebot.example.com",
231+
)
232+
parser.add_argument(
233+
"--api-key",
234+
default=os.environ.get("SOURCEBOT_API_KEY"),
235+
help="Sourcebot owner API key (defaults to SOURCEBOT_API_KEY)",
236+
)
237+
parser.add_argument(
238+
"--inactivity-time",
239+
required=True,
240+
type=parse_duration,
241+
metavar="DURATION",
242+
help="Inactivity threshold using s, m, h, d, or w (for example: 90d)",
243+
)
244+
parser.add_argument("--timeout", type=float, default=30.0, help=argparse.SUPPRESS)
245+
return parser
246+
247+
248+
def main(argv: Sequence[str] | None = None) -> int:
249+
parser = build_parser()
250+
args = parser.parse_args(argv)
251+
if not args.api_key:
252+
parser.error("--api-key is required when SOURCEBOT_API_KEY is not set")
253+
if args.timeout <= 0:
254+
parser.error("--timeout must be greater than zero")
255+
256+
now = datetime.now(timezone.utc)
257+
client = SourcebotClient(args.base_url, args.api_key, args.timeout)
258+
try:
259+
inactive_users = find_inactive_users(client.list_users(), now - args.inactivity_time)
260+
except SourcebotApiError as error:
261+
print(f"Error: {error}", file=sys.stderr)
262+
return 1
263+
264+
if not inactive_users:
265+
print("No users exceed the inactivity threshold.")
266+
return 0
267+
268+
print_users_table(inactive_users, now)
269+
print(f"\n{len(inactive_users)} user(s) will be permanently removed from this organization.")
270+
try:
271+
confirmation = input('Type "deprovision" to continue: ')
272+
except (EOFError, KeyboardInterrupt):
273+
print("\nCancelled. No users were deprovisioned.")
274+
return 0
275+
if confirmation.strip().lower() != "deprovision":
276+
print("Cancelled. No users were deprovisioned.")
277+
return 0
278+
279+
failures = []
280+
for user in inactive_users:
281+
try:
282+
client.deprovision_user(user.id)
283+
print(f"Deprovisioned {user.email}")
284+
except SourcebotApiError as error:
285+
failures.append((user, error))
286+
print(f"Failed to deprovision {user.email}: {error}", file=sys.stderr)
287+
288+
if failures:
289+
print(
290+
f"Deprovisioned {len(inactive_users) - len(failures)} of {len(inactive_users)} user(s); "
291+
f"{len(failures)} failed.",
292+
file=sys.stderr,
293+
)
294+
return 1
295+
296+
print(f"Successfully deprovisioned {len(inactive_users)} user(s).")
297+
return 0
298+
299+
300+
if __name__ == "__main__":
301+
raise SystemExit(main())

0 commit comments

Comments
 (0)