337 lines
12 KiB
Python
337 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Create the least-privilege ContextForge API token without exposing it."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import stat
|
|
import tempfile
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from urllib.parse import quote, urlparse
|
|
|
|
MAX_RESPONSE_BYTES = 1024 * 1024
|
|
RUNTIME_PERMISSIONS = (
|
|
"gateways.read",
|
|
"gateways.create",
|
|
"gateways.update",
|
|
"gateways.delete",
|
|
"tools.read",
|
|
"tools.update",
|
|
"tools.execute",
|
|
"prompts.read",
|
|
"servers.read",
|
|
"servers.create",
|
|
"servers.update",
|
|
"servers.use",
|
|
)
|
|
RUNTIME_READ_CHECKS = (
|
|
"/gateways?limit=1&include_pagination=false",
|
|
"/tools?limit=1&include_pagination=false",
|
|
"/prompts?limit=1&include_pagination=false",
|
|
"/servers?limit=1&include_pagination=false",
|
|
)
|
|
|
|
|
|
class BootstrapError(RuntimeError):
|
|
"""Sanitized bootstrap failure."""
|
|
|
|
|
|
def _validate_base_url(value: str) -> str:
|
|
parsed = urlparse(value)
|
|
allowed = (
|
|
parsed.scheme == "http"
|
|
and parsed.hostname == "contextforge"
|
|
and parsed.port == 4444
|
|
and not parsed.username
|
|
and not parsed.password
|
|
) or (
|
|
parsed.scheme == "https"
|
|
and bool(parsed.hostname)
|
|
and not parsed.username
|
|
and not parsed.password
|
|
)
|
|
if not allowed or parsed.path not in {"", "/"} or parsed.query or parsed.fragment:
|
|
raise BootstrapError("ContextForge bootstrap URL is not approved")
|
|
return value.rstrip("/")
|
|
|
|
|
|
def _read_secret(path: Path) -> str:
|
|
try:
|
|
metadata = path.stat()
|
|
payload = path.read_bytes()
|
|
except OSError as exc:
|
|
raise BootstrapError("ContextForge admin password is unavailable") from exc
|
|
content = payload.rstrip(b"\r\n")
|
|
if (
|
|
not stat.S_ISREG(metadata.st_mode)
|
|
or (
|
|
os.name != "nt"
|
|
and (
|
|
stat.S_IMODE(metadata.st_mode) not in {0o400, 0o600}
|
|
or metadata.st_uid != os.getuid()
|
|
or metadata.st_gid != os.getgid()
|
|
)
|
|
)
|
|
or not content
|
|
or len(content) > 8192
|
|
or b"\x00" in content
|
|
or b"\r" in content
|
|
or b"\n" in content
|
|
):
|
|
raise BootstrapError("ContextForge admin password violates the secret-file policy")
|
|
return content.decode("utf-8")
|
|
|
|
|
|
def _request_json(
|
|
base_url: str,
|
|
method: str,
|
|
path: str,
|
|
*,
|
|
token: str | None = None,
|
|
body: dict[str, Any] | None = None,
|
|
) -> Any:
|
|
headers = {"Accept": "application/json"}
|
|
data = None
|
|
if token:
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
if body is not None:
|
|
headers["Content-Type"] = "application/json"
|
|
data = json.dumps(body, separators=(",", ":")).encode()
|
|
request = urllib.request.Request( # noqa: S310 - URL is validated before use.
|
|
base_url + path, data=data, headers=headers, method=method
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=10) as response: # noqa: S310
|
|
payload = response.read(MAX_RESPONSE_BYTES + 1)
|
|
if len(payload) > MAX_RESPONSE_BYTES:
|
|
raise BootstrapError("ContextForge bootstrap response exceeded its limit")
|
|
return json.loads(payload) if payload else None
|
|
except urllib.error.HTTPError as exc:
|
|
raise BootstrapError(
|
|
f"ContextForge {path.split('?', 1)[0]} returned HTTP {exc.code}"
|
|
) from None
|
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise BootstrapError("ContextForge bootstrap request failed") from exc
|
|
|
|
|
|
def _write_token(path: Path, token: str) -> None:
|
|
if not token or "\n" in token or "\r" in token or "\x00" in token or len(token) > 8192:
|
|
raise BootstrapError("ContextForge returned an invalid API token")
|
|
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
if os.name != "nt" and stat.S_IMODE(path.parent.stat().st_mode) != 0o700:
|
|
raise BootstrapError("ContextForge token directory must have mode 0700")
|
|
descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
try:
|
|
os.fchmod(descriptor, 0o600)
|
|
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
|
stream.write(token)
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
os.replace(temporary, path)
|
|
except Exception:
|
|
try:
|
|
os.close(descriptor)
|
|
except OSError:
|
|
pass
|
|
try:
|
|
os.unlink(temporary)
|
|
except OSError:
|
|
pass
|
|
raise
|
|
|
|
|
|
def _login(base_url: str, admin_email: str, password: str) -> str:
|
|
login = _request_json(
|
|
base_url,
|
|
"POST",
|
|
"/auth/email/login",
|
|
body={"email": admin_email, "password": password},
|
|
)
|
|
session_token = login.get("access_token") if isinstance(login, dict) else None
|
|
if not isinstance(session_token, str) or not session_token:
|
|
raise BootstrapError("ContextForge login did not return a session token")
|
|
return session_token
|
|
|
|
|
|
def _verify_runtime_token(base_url: str, token: str) -> None:
|
|
for path in RUNTIME_READ_CHECKS:
|
|
_request_json(base_url, "GET", path, token=token)
|
|
|
|
|
|
def _revoke_token(base_url: str, session_token: str, token_id: str) -> None:
|
|
if not token_id:
|
|
raise BootstrapError("ContextForge token identifier was invalid")
|
|
encoded_id = quote(token_id, safe="")
|
|
_request_json(base_url, "DELETE", f"/tokens/{encoded_id}", token=session_token)
|
|
|
|
|
|
def _revoke_named_token(base_url: str, session_token: str, token_name: str) -> None:
|
|
catalog = _request_json(
|
|
base_url,
|
|
"GET",
|
|
"/tokens?include_inactive=false&limit=100&offset=0",
|
|
token=session_token,
|
|
)
|
|
tokens = catalog.get("tokens") if isinstance(catalog, dict) else None
|
|
if not isinstance(tokens, list):
|
|
raise BootstrapError("ContextForge token inventory response was invalid")
|
|
matches = [
|
|
item
|
|
for item in tokens
|
|
if isinstance(item, dict)
|
|
and item.get("name") == token_name
|
|
and item.get("is_active") is True
|
|
and item.get("is_revoked") is not True
|
|
]
|
|
if len(matches) != 1 or not isinstance(matches[0].get("id"), str) or not matches[0]["id"]:
|
|
raise BootstrapError("ContextForge token rotation requires one active named token")
|
|
_revoke_token(base_url, session_token, matches[0]["id"])
|
|
|
|
|
|
def bootstrap(
|
|
*,
|
|
base_url: str,
|
|
admin_email: str,
|
|
admin_password_file: Path,
|
|
output_token_file: Path,
|
|
source_network: str,
|
|
token_name: str,
|
|
expires_days: int,
|
|
rotate_existing: bool = False,
|
|
replacement_token_name: str | None = None,
|
|
) -> dict[str, Any]:
|
|
base_url = _validate_base_url(base_url)
|
|
if rotate_existing and (not replacement_token_name or replacement_token_name == token_name):
|
|
raise BootstrapError("ContextForge rotation requires a distinct replacement token name")
|
|
effective_token_name = replacement_token_name if rotate_existing else token_name
|
|
if not effective_token_name:
|
|
raise BootstrapError("ContextForge token name is required")
|
|
password: str | None = None
|
|
session_token: str | None = None
|
|
if output_token_file.exists():
|
|
existing = _read_secret(output_token_file)
|
|
if rotate_existing:
|
|
password = _read_secret(admin_password_file)
|
|
session_token = _login(base_url, admin_email, password)
|
|
_revoke_named_token(base_url, session_token, token_name)
|
|
try:
|
|
output_token_file.unlink()
|
|
except OSError as exc:
|
|
raise BootstrapError(
|
|
"ContextForge revoked token file could not be removed"
|
|
) from exc
|
|
else:
|
|
_verify_runtime_token(base_url, existing)
|
|
return {"status": "pass", "action": "reused", "tokenName": token_name}
|
|
|
|
if password is None:
|
|
password = _read_secret(admin_password_file)
|
|
if session_token is None:
|
|
session_token = _login(base_url, admin_email, password)
|
|
created = _request_json(
|
|
base_url,
|
|
"POST",
|
|
"/tokens",
|
|
token=session_token,
|
|
body={
|
|
"name": effective_token_name,
|
|
"description": "ITWorx MCP Hub production gateway reconciliation",
|
|
"expires_in_days": expires_days,
|
|
"scope": {
|
|
"permissions": list(RUNTIME_PERMISSIONS),
|
|
"ip_restrictions": [source_network],
|
|
"time_restrictions": {},
|
|
"usage_limits": {},
|
|
},
|
|
"tags": ["itworx-mcp-hub", "production"],
|
|
"is_active": True,
|
|
},
|
|
)
|
|
api_token = created.get("access_token") if isinstance(created, dict) else None
|
|
token_record = created.get("token") if isinstance(created, dict) else None
|
|
token_id = token_record.get("id") if isinstance(token_record, dict) else None
|
|
if not isinstance(api_token, str) or not api_token:
|
|
try:
|
|
_revoke_named_token(base_url, session_token, effective_token_name)
|
|
except BootstrapError:
|
|
raise BootstrapError(
|
|
"ContextForge token creation returned no API token and cleanup failed"
|
|
) from None
|
|
raise BootstrapError("ContextForge token creation returned no API token")
|
|
if not isinstance(token_id, str) or not token_id:
|
|
try:
|
|
_revoke_named_token(base_url, session_token, effective_token_name)
|
|
except BootstrapError:
|
|
raise BootstrapError(
|
|
"ContextForge token creation returned no identifier and cleanup failed"
|
|
) from None
|
|
raise BootstrapError("ContextForge token creation returned no identifier")
|
|
try:
|
|
_verify_runtime_token(base_url, api_token)
|
|
_write_token(output_token_file, api_token)
|
|
except Exception as exc:
|
|
try:
|
|
_revoke_token(base_url, session_token, token_id)
|
|
except BootstrapError:
|
|
raise BootstrapError(
|
|
"ContextForge replacement token validation failed and cleanup failed"
|
|
) from None
|
|
if isinstance(exc, BootstrapError):
|
|
raise
|
|
raise BootstrapError("ContextForge replacement token could not be stored") from exc
|
|
return {
|
|
"status": "pass",
|
|
"action": "created",
|
|
"tokenName": effective_token_name,
|
|
"permissions": list(RUNTIME_PERMISSIONS),
|
|
"sourceNetwork": source_network,
|
|
"expiresDays": expires_days,
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--base-url", default="http://contextforge:4444")
|
|
parser.add_argument("--admin-email", required=True)
|
|
parser.add_argument("--admin-password-file", type=Path, required=True)
|
|
parser.add_argument("--output-token-file", type=Path, required=True)
|
|
parser.add_argument("--source-network", default="203.0.113.0/24")
|
|
parser.add_argument("--token-name", default="itworx-mcp-hub-runtime-prod")
|
|
parser.add_argument("--expires-days", type=int, default=365)
|
|
parser.add_argument(
|
|
"--rotate-existing",
|
|
action="store_true",
|
|
help=(
|
|
"revoke exactly one invalid named token before creating a distinctly named replacement"
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--replacement-token-name",
|
|
help="unique replacement name required with --rotate-existing",
|
|
)
|
|
args = parser.parse_args()
|
|
if not 1 <= args.expires_days <= 365:
|
|
raise BootstrapError("ContextForge token expiry must be between 1 and 365 days")
|
|
result = bootstrap(
|
|
base_url=args.base_url,
|
|
admin_email=args.admin_email,
|
|
admin_password_file=args.admin_password_file,
|
|
output_token_file=args.output_token_file,
|
|
source_network=args.source_network,
|
|
token_name=args.token_name,
|
|
expires_days=args.expires_days,
|
|
rotate_existing=args.rotate_existing,
|
|
replacement_token_name=args.replacement_token_name,
|
|
)
|
|
print(json.dumps(result, sort_keys=True))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|