boxadm-mcp 0.3.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- boxadm_mcp/__init__.py +3 -0
- boxadm_mcp/__main__.py +49 -0
- boxadm_mcp/client.py +470 -0
- boxadm_mcp/config.py +42 -0
- boxadm_mcp/oauth.py +99 -0
- boxadm_mcp/server.py +626 -0
- boxadm_mcp-0.3.2.dist-info/METADATA +241 -0
- boxadm_mcp-0.3.2.dist-info/RECORD +12 -0
- boxadm_mcp-0.3.2.dist-info/WHEEL +5 -0
- boxadm_mcp-0.3.2.dist-info/entry_points.txt +2 -0
- boxadm_mcp-0.3.2.dist-info/licenses/LICENSE +21 -0
- boxadm_mcp-0.3.2.dist-info/top_level.txt +1 -0
boxadm_mcp/__init__.py
ADDED
boxadm_mcp/__main__.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Entry point: `boxadm-mcp` (stdio server) / `boxadm-mcp auth` / `boxadm-mcp --version`."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from boxadm_mcp import __version__
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _auth() -> None:
|
|
11
|
+
"""One-time OAuth login: writes the token cache for BoxOAuthClient."""
|
|
12
|
+
from boxadm_mcp.oauth import DEFAULT_REDIRECT_URI, login
|
|
13
|
+
|
|
14
|
+
cache = login(
|
|
15
|
+
os.environ["BOX_CLIENT_ID"],
|
|
16
|
+
os.environ["BOX_CLIENT_SECRET"],
|
|
17
|
+
redirect_uri=os.environ.get("BOX_OAUTH_REDIRECT_URI", DEFAULT_REDIRECT_URI),
|
|
18
|
+
token_cache=os.environ.get("BOX_TOKEN_CACHE") or None,
|
|
19
|
+
)
|
|
20
|
+
print(f"token cache written: {cache}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main() -> None:
|
|
24
|
+
argv = sys.argv[1:]
|
|
25
|
+
if "--version" in argv:
|
|
26
|
+
print(f"boxadm-mcp {__version__}")
|
|
27
|
+
return
|
|
28
|
+
if argv and argv[0] == "auth":
|
|
29
|
+
_auth()
|
|
30
|
+
return
|
|
31
|
+
try:
|
|
32
|
+
# Import lazily so `--version` / `auth` work without the MCP runtime.
|
|
33
|
+
# The import sits inside the try so a ^C during the (slow) import chain
|
|
34
|
+
# also exits cleanly, not just one delivered while the server runs.
|
|
35
|
+
from boxadm_mcp.server import mcp
|
|
36
|
+
|
|
37
|
+
mcp.run()
|
|
38
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
39
|
+
# anyio's teardown on SIGINT dumps a 20-80 line traceback. What it
|
|
40
|
+
# raises out of mcp.run() is Python-version-dependent: a bare
|
|
41
|
+
# KeyboardInterrupt on 3.12/3.13, but asyncio.CancelledError on 3.10
|
|
42
|
+
# (asyncio.Runner.run() re-raises CancelledError instead of letting
|
|
43
|
+
# KeyboardInterrupt propagate). Catch both and exit clean, same
|
|
44
|
+
# convention as the sibling fleet MCP servers.
|
|
45
|
+
os._exit(0)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
if __name__ == "__main__":
|
|
49
|
+
main()
|
boxadm_mcp/client.py
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
"""Box Enterprise API clients (read-only).
|
|
2
|
+
|
|
3
|
+
Two auth modes, same read-only surface (`authenticate()` + `get_admin_events()`):
|
|
4
|
+
|
|
5
|
+
- ``BoxClient`` — Client Credentials Grant (server-to-server, enterprise subject).
|
|
6
|
+
- ``BoxOAuthClient`` — OAuth 2.0 (user auth) with a cached, auto-refreshed token.
|
|
7
|
+
A one-time interactive login (``boxadm-mcp auth``) writes the token cache; the
|
|
8
|
+
client refreshes the access token from the stored refresh token as needed and
|
|
9
|
+
persists the rotated refresh token (Box rotates it on every refresh).
|
|
10
|
+
|
|
11
|
+
Read-only by design: neither client issues a mutating call. boxadm-mcp surfaces
|
|
12
|
+
risk; it never enforces.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import fcntl
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import time
|
|
19
|
+
from contextlib import contextmanager
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
DEFAULT_TIMEOUT = 30
|
|
25
|
+
DEFAULT_API_BASE = "https://api.box.com"
|
|
26
|
+
TOKEN_PATH = "/oauth2/token"
|
|
27
|
+
API_PREFIX = "/2.0"
|
|
28
|
+
|
|
29
|
+
# Refresh a token this many seconds before its stated expiry, so a call never
|
|
30
|
+
# races a just-expired token.
|
|
31
|
+
TOKEN_REFRESH_SKEW = 60
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class BoxError(Exception):
|
|
35
|
+
"""Base error for Box API failures."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BoxAuthError(BoxError):
|
|
39
|
+
"""Raised when authentication fails (bad creds / app not authorized)."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class BoxNotAuthenticatedError(BoxAuthError):
|
|
43
|
+
"""No usable OAuth token cache — run ``boxadm-mcp auth`` to log in."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def default_token_cache() -> str:
|
|
47
|
+
"""Default OAuth token cache path (override via BOX_TOKEN_CACHE)."""
|
|
48
|
+
return os.path.expanduser("~/.config/boxadm-mcp/token.json")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@contextmanager
|
|
52
|
+
def cache_lock(path: str):
|
|
53
|
+
"""Exclusive cross-process lock guarding token-cache read/refresh/write.
|
|
54
|
+
|
|
55
|
+
Box refresh tokens are single-use and rotate on every refresh; two processes
|
|
56
|
+
sharing one cache file must never refresh concurrently (Box treats reuse of a
|
|
57
|
+
rotated token as compromise and revokes the whole chain). The lock is a
|
|
58
|
+
persistent sidecar ``<cache>.lock`` file rather than the cache itself, because
|
|
59
|
+
``write_token_cache`` replaces the cache inode (``os.replace``) — a lock held
|
|
60
|
+
on a replaced inode would no longer exclude the next locker.
|
|
61
|
+
"""
|
|
62
|
+
lock_path = path + ".lock"
|
|
63
|
+
try:
|
|
64
|
+
Path(lock_path).parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
fd = os.open(lock_path, os.O_WRONLY | os.O_CREAT, 0o600)
|
|
66
|
+
except OSError as e:
|
|
67
|
+
# Wrap into the Box exception hierarchy so callers (health_check, tool
|
|
68
|
+
# handlers) keep their "structured error, never a raw traceback" contract.
|
|
69
|
+
# Typical trigger: the sidecar was created by the wrong user (e.g. `auth`
|
|
70
|
+
# run without sudo -u mcp) — fix ownership or remove the .lock file.
|
|
71
|
+
raise BoxAuthError(f"token cache lock unavailable: {lock_path}: {e}") from e
|
|
72
|
+
try:
|
|
73
|
+
try:
|
|
74
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
75
|
+
except OSError as e:
|
|
76
|
+
raise BoxAuthError(f"token cache lock failed: {lock_path}: {e}") from e
|
|
77
|
+
yield
|
|
78
|
+
finally:
|
|
79
|
+
os.close(fd) # closing the fd releases the flock
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def write_token_cache(path: str, data: dict) -> None:
|
|
83
|
+
"""Write the token cache atomically and owner-only (0600).
|
|
84
|
+
|
|
85
|
+
Created at 0600 from the start (no world-readable window), written to a temp
|
|
86
|
+
file then ``os.replace``d in, so a crash mid-write can't corrupt the cache and
|
|
87
|
+
trigger a spurious re-login.
|
|
88
|
+
"""
|
|
89
|
+
p = Path(path)
|
|
90
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
tmp = p.with_name(p.name + ".tmp")
|
|
92
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
93
|
+
try:
|
|
94
|
+
with os.fdopen(fd, "w") as f:
|
|
95
|
+
json.dump(data, f)
|
|
96
|
+
except BaseException:
|
|
97
|
+
try:
|
|
98
|
+
os.unlink(tmp)
|
|
99
|
+
except OSError:
|
|
100
|
+
pass
|
|
101
|
+
raise
|
|
102
|
+
os.replace(tmp, p) # atomic; the 0600 temp inode becomes the cache
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def save_token_cache(path: str, access: str, refresh: str, expires_in: int) -> None:
|
|
106
|
+
"""Persist a token-endpoint result. Single source of truth for the cache schema."""
|
|
107
|
+
write_token_cache(
|
|
108
|
+
path,
|
|
109
|
+
{"access_token": access, "refresh_token": refresh, "access_expires_at": int(time.time()) + int(expires_in)},
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _events_params(
|
|
114
|
+
created_after: str | None,
|
|
115
|
+
created_before: str | None,
|
|
116
|
+
event_types: list[str] | None,
|
|
117
|
+
stream_position: str | int | None,
|
|
118
|
+
limit: int,
|
|
119
|
+
) -> dict:
|
|
120
|
+
"""Build query params for an enterprise ``admin_logs`` events page."""
|
|
121
|
+
params: dict = {"stream_type": "admin_logs", "limit": limit}
|
|
122
|
+
if created_after:
|
|
123
|
+
params["created_after"] = created_after
|
|
124
|
+
if created_before:
|
|
125
|
+
params["created_before"] = created_before
|
|
126
|
+
if event_types:
|
|
127
|
+
params["event_type"] = ",".join(event_types)
|
|
128
|
+
if stream_position is not None:
|
|
129
|
+
params["stream_position"] = stream_position
|
|
130
|
+
return params
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def fetch_admin_events(
|
|
134
|
+
client,
|
|
135
|
+
*,
|
|
136
|
+
created_after: str | None = None,
|
|
137
|
+
created_before: str | None = None,
|
|
138
|
+
event_types: list[str] | None = None,
|
|
139
|
+
max_events: int = 5000,
|
|
140
|
+
page_size: int = 500,
|
|
141
|
+
created_by_logins: list[str] | None = None,
|
|
142
|
+
) -> tuple[list[dict], bool]:
|
|
143
|
+
"""Page through ``admin_logs`` and collect events from the stream.
|
|
144
|
+
|
|
145
|
+
Works with either client (both expose ``get_admin_events``). Returns
|
|
146
|
+
``(events, capped)`` where ``capped`` is True when the ``max_events`` cap was
|
|
147
|
+
hit before the stream was exhausted — so callers can disclose truncation
|
|
148
|
+
rather than silently under-report.
|
|
149
|
+
|
|
150
|
+
``max_events`` bounds the number of events *scanned* from the stream (the
|
|
151
|
+
volume-safety limit), not the number kept. When ``created_by_logins`` is set,
|
|
152
|
+
only events whose ``created_by.login`` is in that set are kept (a client-side
|
|
153
|
+
filter — Box admin_logs has no ``created_by`` query param), so a specific
|
|
154
|
+
accessor can be traced across the whole window while API work stays bounded.
|
|
155
|
+
With no filter, kept == scanned and the behaviour is unchanged.
|
|
156
|
+
"""
|
|
157
|
+
logins = set(created_by_logins) if created_by_logins else None
|
|
158
|
+
out: list[dict] = []
|
|
159
|
+
pos: str | int | None = None
|
|
160
|
+
scanned = 0
|
|
161
|
+
capped = False
|
|
162
|
+
while True:
|
|
163
|
+
if scanned >= max_events:
|
|
164
|
+
capped = True
|
|
165
|
+
break
|
|
166
|
+
resp = client.get_admin_events(
|
|
167
|
+
created_after=created_after,
|
|
168
|
+
created_before=created_before,
|
|
169
|
+
event_types=event_types,
|
|
170
|
+
stream_position=pos,
|
|
171
|
+
limit=min(page_size, max_events - scanned),
|
|
172
|
+
)
|
|
173
|
+
entries = resp.get("entries", [])
|
|
174
|
+
scanned += len(entries)
|
|
175
|
+
if logins is None:
|
|
176
|
+
out.extend(entries)
|
|
177
|
+
else:
|
|
178
|
+
out.extend(e for e in entries if ((e.get("created_by") or {}).get("login")) in logins)
|
|
179
|
+
nxt = resp.get("next_stream_position")
|
|
180
|
+
if not entries or nxt is None or str(nxt) == str(pos):
|
|
181
|
+
break
|
|
182
|
+
pos = nxt
|
|
183
|
+
return out[:max_events], capped
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class _FolderReadMixin:
|
|
187
|
+
"""Read-only folder / collaboration / shared-link getters shared by both clients.
|
|
188
|
+
|
|
189
|
+
Relies on the subclass providing ``_get(path, params)``.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
def get_folder(self, folder_id: str, *, fields: list[str] | None = None) -> dict:
|
|
193
|
+
return self._get(f"/folders/{folder_id}", {"fields": ",".join(fields)} if fields else None)
|
|
194
|
+
|
|
195
|
+
def get_folder_items(self, folder_id: str, *, fields: list[str] | None = None, limit: int = 1000, offset: int = 0) -> dict:
|
|
196
|
+
params: dict = {"limit": limit, "offset": offset}
|
|
197
|
+
if fields:
|
|
198
|
+
params["fields"] = ",".join(fields)
|
|
199
|
+
return self._get(f"/folders/{folder_id}/items", params)
|
|
200
|
+
|
|
201
|
+
def get_folder_collaborations(self, folder_id: str) -> dict:
|
|
202
|
+
return self._get(f"/folders/{folder_id}/collaborations")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class BoxClient(_FolderReadMixin):
|
|
206
|
+
"""Read-only Box enterprise client using the CCG flow."""
|
|
207
|
+
|
|
208
|
+
def __init__(
|
|
209
|
+
self,
|
|
210
|
+
client_id: str,
|
|
211
|
+
client_secret: str,
|
|
212
|
+
enterprise_id: str,
|
|
213
|
+
*,
|
|
214
|
+
api_base: str = DEFAULT_API_BASE,
|
|
215
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
216
|
+
):
|
|
217
|
+
self._client_id = client_id
|
|
218
|
+
self._client_secret = client_secret
|
|
219
|
+
self._enterprise_id = enterprise_id
|
|
220
|
+
self._base = api_base.rstrip("/")
|
|
221
|
+
self._http = httpx.Client(timeout=timeout)
|
|
222
|
+
self._token: str | None = None
|
|
223
|
+
self._token_deadline = 0.0 # monotonic clock deadline
|
|
224
|
+
|
|
225
|
+
def _ensure_token(self) -> str:
|
|
226
|
+
if self._token and time.monotonic() < self._token_deadline:
|
|
227
|
+
return self._token
|
|
228
|
+
data = {
|
|
229
|
+
"grant_type": "client_credentials",
|
|
230
|
+
"client_id": self._client_id,
|
|
231
|
+
"client_secret": self._client_secret,
|
|
232
|
+
"box_subject_type": "enterprise",
|
|
233
|
+
"box_subject_id": self._enterprise_id,
|
|
234
|
+
}
|
|
235
|
+
try:
|
|
236
|
+
resp = self._http.post(self._base + TOKEN_PATH, data=data)
|
|
237
|
+
resp.raise_for_status()
|
|
238
|
+
body = resp.json()
|
|
239
|
+
except httpx.HTTPStatusError as e:
|
|
240
|
+
raise BoxAuthError(f"token request failed: HTTP {e.response.status_code}") from e
|
|
241
|
+
except httpx.HTTPError as e:
|
|
242
|
+
raise BoxAuthError(f"token request error: {e}") from e
|
|
243
|
+
try:
|
|
244
|
+
self._token = body["access_token"]
|
|
245
|
+
except (KeyError, TypeError) as e:
|
|
246
|
+
raise BoxAuthError("token response missing access_token") from e
|
|
247
|
+
self._token_deadline = time.monotonic() + max(0, int(body.get("expires_in", 3600)) - TOKEN_REFRESH_SKEW)
|
|
248
|
+
return self._token
|
|
249
|
+
|
|
250
|
+
def authenticate(self) -> bool:
|
|
251
|
+
"""Obtain (or refresh) the CCG token. Returns True on success, else raises."""
|
|
252
|
+
self._ensure_token()
|
|
253
|
+
return True
|
|
254
|
+
|
|
255
|
+
def _get(self, path: str, params: dict | None = None, *, _retry_auth: bool = True) -> dict:
|
|
256
|
+
token = self._ensure_token()
|
|
257
|
+
try:
|
|
258
|
+
resp = self._http.get(
|
|
259
|
+
self._base + API_PREFIX + path,
|
|
260
|
+
params=params,
|
|
261
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
262
|
+
)
|
|
263
|
+
resp.raise_for_status()
|
|
264
|
+
return resp.json()
|
|
265
|
+
except httpx.HTTPStatusError as e:
|
|
266
|
+
if e.response.status_code == 401 and _retry_auth:
|
|
267
|
+
self._token = None
|
|
268
|
+
self._token_deadline = 0.0
|
|
269
|
+
return self._get(path, params, _retry_auth=False)
|
|
270
|
+
raise BoxError(f"HTTP {e.response.status_code}: GET {path}") from e
|
|
271
|
+
except httpx.HTTPError as e:
|
|
272
|
+
raise BoxError(f"connection error: GET {path}: {e}") from e
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def enterprise_id(self) -> str:
|
|
276
|
+
return self._enterprise_id
|
|
277
|
+
|
|
278
|
+
def get_admin_events(
|
|
279
|
+
self,
|
|
280
|
+
*,
|
|
281
|
+
created_after: str | None = None,
|
|
282
|
+
created_before: str | None = None,
|
|
283
|
+
event_types: list[str] | None = None,
|
|
284
|
+
stream_position: str | int | None = None,
|
|
285
|
+
limit: int = 500,
|
|
286
|
+
) -> dict:
|
|
287
|
+
"""Fetch one page of enterprise ``admin_logs`` events (raw Box response)."""
|
|
288
|
+
return self._get("/events", _events_params(created_after, created_before, event_types, stream_position, limit))
|
|
289
|
+
|
|
290
|
+
def close(self) -> None:
|
|
291
|
+
self._http.close()
|
|
292
|
+
|
|
293
|
+
def __enter__(self) -> "BoxClient":
|
|
294
|
+
return self
|
|
295
|
+
|
|
296
|
+
def __exit__(self, *exc) -> None:
|
|
297
|
+
self.close()
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class BoxOAuthClient(_FolderReadMixin):
|
|
301
|
+
"""Read-only Box client using OAuth 2.0 (user auth) with an auto-refreshed token cache."""
|
|
302
|
+
|
|
303
|
+
def __init__(
|
|
304
|
+
self,
|
|
305
|
+
client_id: str,
|
|
306
|
+
client_secret: str,
|
|
307
|
+
*,
|
|
308
|
+
token_cache: str | None = None,
|
|
309
|
+
api_base: str = DEFAULT_API_BASE,
|
|
310
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
311
|
+
):
|
|
312
|
+
self._client_id = client_id
|
|
313
|
+
self._client_secret = client_secret
|
|
314
|
+
self._cache_path = token_cache or default_token_cache()
|
|
315
|
+
self._base = api_base.rstrip("/")
|
|
316
|
+
self._http = httpx.Client(timeout=timeout)
|
|
317
|
+
self._access: str | None = None
|
|
318
|
+
self._access_deadline = 0.0 # unix time (persisted across restarts)
|
|
319
|
+
|
|
320
|
+
# ------------------------------------------------------------------
|
|
321
|
+
# Token cache
|
|
322
|
+
# ------------------------------------------------------------------
|
|
323
|
+
def _load_cache(self) -> dict:
|
|
324
|
+
try:
|
|
325
|
+
with open(self._cache_path) as f:
|
|
326
|
+
return json.load(f)
|
|
327
|
+
except (OSError, ValueError):
|
|
328
|
+
return {}
|
|
329
|
+
|
|
330
|
+
def _save_cache(self, access: str, refresh: str, expires_in: int) -> None:
|
|
331
|
+
save_token_cache(self._cache_path, access, refresh, expires_in)
|
|
332
|
+
|
|
333
|
+
# ------------------------------------------------------------------
|
|
334
|
+
# Auth (refresh-token grant)
|
|
335
|
+
# ------------------------------------------------------------------
|
|
336
|
+
def _adopt_valid_access(self, cache: dict) -> str | None:
|
|
337
|
+
"""Adopt the cached access token if it is still comfortably valid."""
|
|
338
|
+
access = cache.get("access_token")
|
|
339
|
+
expires_at = cache.get("access_expires_at", 0)
|
|
340
|
+
if access and time.time() < expires_at - TOKEN_REFRESH_SKEW:
|
|
341
|
+
self._access, self._access_deadline = access, expires_at
|
|
342
|
+
return access
|
|
343
|
+
return None
|
|
344
|
+
|
|
345
|
+
def _ensure_token(self) -> str:
|
|
346
|
+
if self._access and time.time() < self._access_deadline - TOKEN_REFRESH_SKEW:
|
|
347
|
+
return self._access
|
|
348
|
+
# Unlocked disk fast path: right after another process refreshed, adopt
|
|
349
|
+
# its result without serializing every reader through the lock. A miss
|
|
350
|
+
# (or a torn read) just falls through to the locked slow path.
|
|
351
|
+
access = self._adopt_valid_access(self._load_cache())
|
|
352
|
+
if access:
|
|
353
|
+
return access
|
|
354
|
+
# Slow path: refresh under the cross-process lock. Another process may
|
|
355
|
+
# win the race and rotate the refresh token while we wait for the lock,
|
|
356
|
+
# so re-read the cache once inside it — if a fresh access token appeared,
|
|
357
|
+
# use it and skip our own refresh (refreshing with the pre-lock token
|
|
358
|
+
# would present an already-rotated token and revoke the chain).
|
|
359
|
+
with cache_lock(self._cache_path):
|
|
360
|
+
cache = self._load_cache()
|
|
361
|
+
access = self._adopt_valid_access(cache)
|
|
362
|
+
if access:
|
|
363
|
+
return access
|
|
364
|
+
refresh = cache.get("refresh_token")
|
|
365
|
+
if not refresh:
|
|
366
|
+
raise BoxNotAuthenticatedError("no token cache; run 'boxadm-mcp auth' to log in")
|
|
367
|
+
return self._refresh(refresh)
|
|
368
|
+
|
|
369
|
+
def _refresh(self, refresh_token: str) -> str:
|
|
370
|
+
# Caller must hold cache_lock(self._cache_path): the refresh token is
|
|
371
|
+
# single-use and the rotated result is persisted here.
|
|
372
|
+
data = {
|
|
373
|
+
"grant_type": "refresh_token",
|
|
374
|
+
"refresh_token": refresh_token,
|
|
375
|
+
"client_id": self._client_id,
|
|
376
|
+
"client_secret": self._client_secret,
|
|
377
|
+
}
|
|
378
|
+
try:
|
|
379
|
+
resp = self._http.post(self._base + TOKEN_PATH, data=data)
|
|
380
|
+
resp.raise_for_status()
|
|
381
|
+
body = resp.json()
|
|
382
|
+
except httpx.HTTPStatusError as e:
|
|
383
|
+
status = e.response.status_code
|
|
384
|
+
if 400 <= status < 500 and status != 429:
|
|
385
|
+
# Refresh tokens last ~60 days and are single-use; a 4xx here means
|
|
386
|
+
# the chain is broken — re-login is required.
|
|
387
|
+
raise BoxNotAuthenticatedError(f"token refresh failed (HTTP {status}); run 'boxadm-mcp auth'") from e
|
|
388
|
+
# 5xx/429 is a transient server-side failure: the refresh token was
|
|
389
|
+
# not consumed, so the next call can retry with the same token —
|
|
390
|
+
# do NOT steer the operator toward an unnecessary re-login.
|
|
391
|
+
raise BoxAuthError(f"token refresh failed (HTTP {status}); transient — retry later") from e
|
|
392
|
+
except httpx.HTTPError as e:
|
|
393
|
+
# Residual risk with no client-side fix: if Box committed the rotation
|
|
394
|
+
# but the response was lost (timeout/disconnect), the cached refresh
|
|
395
|
+
# token is already consumed and the next retry trips Box's reuse
|
|
396
|
+
# detection (manual re-auth). The lock cannot prevent this class.
|
|
397
|
+
raise BoxAuthError(f"token refresh error: {e}") from e
|
|
398
|
+
try:
|
|
399
|
+
access = body["access_token"]
|
|
400
|
+
except (KeyError, TypeError) as e:
|
|
401
|
+
raise BoxAuthError("token response missing access_token") from e
|
|
402
|
+
new_refresh = body.get("refresh_token", refresh_token) # Box rotates it
|
|
403
|
+
expires_in = int(body.get("expires_in", 3600))
|
|
404
|
+
self._save_cache(access, new_refresh, expires_in)
|
|
405
|
+
self._access, self._access_deadline = access, int(time.time()) + expires_in
|
|
406
|
+
return access
|
|
407
|
+
|
|
408
|
+
def _force_refresh(self) -> None:
|
|
409
|
+
rejected = self._access
|
|
410
|
+
with cache_lock(self._cache_path):
|
|
411
|
+
# The token that just got a 401 may already have been replaced by
|
|
412
|
+
# another process; adopt the newer on-disk one instead of refreshing.
|
|
413
|
+
# If the adopted token is itself revoked (whole-chain revocation),
|
|
414
|
+
# the retried call fails once with a generic 401; the next call then
|
|
415
|
+
# takes the refresh path below and surfaces needs-login.
|
|
416
|
+
cache = self._load_cache()
|
|
417
|
+
access = cache.get("access_token")
|
|
418
|
+
expires_at = cache.get("access_expires_at", 0)
|
|
419
|
+
if access and access != rejected and time.time() < expires_at - TOKEN_REFRESH_SKEW:
|
|
420
|
+
self._access, self._access_deadline = access, expires_at
|
|
421
|
+
return
|
|
422
|
+
refresh = cache.get("refresh_token")
|
|
423
|
+
if not refresh:
|
|
424
|
+
raise BoxNotAuthenticatedError("no token cache; run 'boxadm-mcp auth' to log in")
|
|
425
|
+
self._refresh(refresh)
|
|
426
|
+
|
|
427
|
+
def authenticate(self) -> bool:
|
|
428
|
+
"""Ensure a valid access token (refreshing if needed). Returns True or raises."""
|
|
429
|
+
self._ensure_token()
|
|
430
|
+
return True
|
|
431
|
+
|
|
432
|
+
def _get(self, path: str, params: dict | None = None, *, _retry_auth: bool = True) -> dict:
|
|
433
|
+
token = self._ensure_token()
|
|
434
|
+
try:
|
|
435
|
+
resp = self._http.get(
|
|
436
|
+
self._base + API_PREFIX + path,
|
|
437
|
+
params=params,
|
|
438
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
439
|
+
)
|
|
440
|
+
resp.raise_for_status()
|
|
441
|
+
return resp.json()
|
|
442
|
+
except httpx.HTTPStatusError as e:
|
|
443
|
+
if e.response.status_code == 401 and _retry_auth:
|
|
444
|
+
# Access token revoked before its deadline — force a fresh one.
|
|
445
|
+
self._force_refresh()
|
|
446
|
+
return self._get(path, params, _retry_auth=False)
|
|
447
|
+
raise BoxError(f"HTTP {e.response.status_code}: GET {path}") from e
|
|
448
|
+
except httpx.HTTPError as e:
|
|
449
|
+
raise BoxError(f"connection error: GET {path}: {e}") from e
|
|
450
|
+
|
|
451
|
+
def get_admin_events(
|
|
452
|
+
self,
|
|
453
|
+
*,
|
|
454
|
+
created_after: str | None = None,
|
|
455
|
+
created_before: str | None = None,
|
|
456
|
+
event_types: list[str] | None = None,
|
|
457
|
+
stream_position: str | int | None = None,
|
|
458
|
+
limit: int = 500,
|
|
459
|
+
) -> dict:
|
|
460
|
+
"""Fetch one page of enterprise ``admin_logs`` events (raw Box response)."""
|
|
461
|
+
return self._get("/events", _events_params(created_after, created_before, event_types, stream_position, limit))
|
|
462
|
+
|
|
463
|
+
def close(self) -> None:
|
|
464
|
+
self._http.close()
|
|
465
|
+
|
|
466
|
+
def __enter__(self) -> "BoxOAuthClient":
|
|
467
|
+
return self
|
|
468
|
+
|
|
469
|
+
def __exit__(self, *exc) -> None:
|
|
470
|
+
self.close()
|
boxadm_mcp/config.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Configuration: organization domain allowlist and external-actor detection.
|
|
2
|
+
|
|
3
|
+
The whole point of boxadm-mcp is to surface *external* file flow, so the one piece
|
|
4
|
+
of org-specific knowledge it needs is "which email domains are us". Everything
|
|
5
|
+
else (who shares, what leaks) is derived from Box events relative to this list.
|
|
6
|
+
|
|
7
|
+
No organization-specific value is hardcoded: set ``BOX_ALLOWED_DOMAINS``
|
|
8
|
+
(comma-separated) to your own domains. Left unset, the allowlist is empty and
|
|
9
|
+
every address is treated as external — a safe default for a leakage-detection
|
|
10
|
+
tool (nothing is silently trusted as internal until you configure it).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
|
|
15
|
+
DEFAULT_ALLOWED_DOMAINS: tuple[str, ...] = ()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def allowed_domains() -> list[str]:
|
|
19
|
+
"""Return the internal (org) email domains, lower-cased.
|
|
20
|
+
|
|
21
|
+
Reads BOX_ALLOWED_DOMAINS (comma-separated); empty/unset yields no domains,
|
|
22
|
+
so is_external() treats every address as external until configured.
|
|
23
|
+
"""
|
|
24
|
+
raw = os.environ.get("BOX_ALLOWED_DOMAINS")
|
|
25
|
+
if not raw:
|
|
26
|
+
return list(DEFAULT_ALLOWED_DOMAINS)
|
|
27
|
+
return [d.strip().lower() for d in raw.split(",") if d.strip()]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_external(email: str | None, domains: list[str] | None = None) -> bool:
|
|
31
|
+
"""True when an email address is outside the organization.
|
|
32
|
+
|
|
33
|
+
A blank/missing/malformed address is treated as external — an unknown actor
|
|
34
|
+
is the cautious assumption for a leakage check. Subdomains of an allowed
|
|
35
|
+
domain (e.g. ``sub.example.com`` when ``example.com`` is allowed) count as
|
|
36
|
+
internal.
|
|
37
|
+
"""
|
|
38
|
+
if not email or "@" not in email:
|
|
39
|
+
return True
|
|
40
|
+
doms = domains if domains is not None else allowed_domains()
|
|
41
|
+
dom = email.rsplit("@", 1)[1].strip().lower()
|
|
42
|
+
return not any(dom == d or dom.endswith("." + d) for d in doms)
|
boxadm_mcp/oauth.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Interactive OAuth 2.0 login — writes the token cache used by BoxOAuthClient.
|
|
2
|
+
|
|
3
|
+
Run once via ``boxadm-mcp auth``: opens the Box authorize URL, captures the
|
|
4
|
+
redirect on the local callback, exchanges the code for tokens, and persists the
|
|
5
|
+
refresh token (chmod 600). After this, the MCP server refreshes unattended.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import http.server
|
|
9
|
+
import time
|
|
10
|
+
import urllib.parse
|
|
11
|
+
import webbrowser
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from boxadm_mcp.client import DEFAULT_API_BASE, TOKEN_PATH, cache_lock, default_token_cache, save_token_cache
|
|
16
|
+
|
|
17
|
+
AUTHORIZE_URL = "https://account.box.com/api/oauth2/authorize"
|
|
18
|
+
DEFAULT_REDIRECT_URI = "http://localhost:8787/callback"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_authorize_url(client_id: str, redirect_uri: str, state: str) -> str:
|
|
22
|
+
"""Construct the Box OAuth 2.0 authorize URL (pure, for testing)."""
|
|
23
|
+
return (
|
|
24
|
+
AUTHORIZE_URL + "?" + urllib.parse.urlencode({"response_type": "code", "client_id": client_id, "redirect_uri": redirect_uri, "state": state})
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _write_cache(token_cache: str, body: dict) -> None:
|
|
29
|
+
# Take the same cross-process lock as BoxOAuthClient's refresh path, so an
|
|
30
|
+
# interactive re-login can't interleave with a concurrent refresh rotation.
|
|
31
|
+
with cache_lock(token_cache):
|
|
32
|
+
save_token_cache(token_cache, body["access_token"], body["refresh_token"], int(body.get("expires_in", 3600)))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def login(
|
|
36
|
+
client_id: str,
|
|
37
|
+
client_secret: str,
|
|
38
|
+
*,
|
|
39
|
+
redirect_uri: str = DEFAULT_REDIRECT_URI,
|
|
40
|
+
token_cache: str | None = None,
|
|
41
|
+
api_base: str = DEFAULT_API_BASE,
|
|
42
|
+
open_browser: bool = True,
|
|
43
|
+
) -> str:
|
|
44
|
+
"""Run the authorization-code flow and write the token cache. Returns its path."""
|
|
45
|
+
token_cache = token_cache or default_token_cache()
|
|
46
|
+
state = "boxadm-" + str(int(time.time()))
|
|
47
|
+
authorize = build_authorize_url(client_id, redirect_uri, state)
|
|
48
|
+
|
|
49
|
+
parsed = urllib.parse.urlparse(redirect_uri)
|
|
50
|
+
host, port, cb_path = parsed.hostname or "localhost", parsed.port or 80, parsed.path or "/"
|
|
51
|
+
captured: dict = {}
|
|
52
|
+
|
|
53
|
+
class Handler(http.server.BaseHTTPRequestHandler):
|
|
54
|
+
def do_GET(self):
|
|
55
|
+
q = urllib.parse.urlparse(self.path)
|
|
56
|
+
if q.path != cb_path:
|
|
57
|
+
self.send_response(404)
|
|
58
|
+
self.end_headers()
|
|
59
|
+
return
|
|
60
|
+
p = urllib.parse.parse_qs(q.query)
|
|
61
|
+
captured["code"] = p.get("code", [None])[0]
|
|
62
|
+
captured["error"] = p.get("error", [None])[0]
|
|
63
|
+
captured["state"] = p.get("state", [None])[0]
|
|
64
|
+
self.send_response(200)
|
|
65
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
66
|
+
self.end_headers()
|
|
67
|
+
self.wfile.write("認可完了。このタブを閉じてターミナルに戻ってください。".encode())
|
|
68
|
+
|
|
69
|
+
def log_message(self, *a):
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
print("Open this URL in your browser to authorize:\n" + authorize, flush=True)
|
|
73
|
+
if open_browser:
|
|
74
|
+
try:
|
|
75
|
+
webbrowser.open(authorize)
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
srv = http.server.HTTPServer((host, port), Handler)
|
|
80
|
+
srv.timeout = 600
|
|
81
|
+
while "code" not in captured and "error" not in captured:
|
|
82
|
+
srv.handle_request()
|
|
83
|
+
|
|
84
|
+
if captured.get("error") or not captured.get("code"):
|
|
85
|
+
raise RuntimeError(f"authorization failed: {captured.get('error')}")
|
|
86
|
+
if captured.get("state") != state:
|
|
87
|
+
raise RuntimeError("state mismatch — possible CSRF; aborting")
|
|
88
|
+
|
|
89
|
+
data = {
|
|
90
|
+
"grant_type": "authorization_code",
|
|
91
|
+
"code": captured["code"],
|
|
92
|
+
"client_id": client_id,
|
|
93
|
+
"client_secret": client_secret,
|
|
94
|
+
"redirect_uri": redirect_uri,
|
|
95
|
+
}
|
|
96
|
+
resp = httpx.post(api_base.rstrip("/") + TOKEN_PATH, data=data, timeout=30)
|
|
97
|
+
resp.raise_for_status()
|
|
98
|
+
_write_cache(token_cache, resp.json())
|
|
99
|
+
return token_cache
|