new-site/scripts/workers/cdr_sftpgo_provisioner.py
justin f8cd37ac8c Initial commit — Performance West telecom compliance platform
Includes: API (Express/TypeScript), Astro site, Python workers,
document generators, FCC compliance tools, Canada CRTC formation,
Ansible infrastructure, and deployment scripts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 06:54:22 -05:00

208 lines
7.5 KiB
Python

"""
SFTPGo provisioner — admin API client.
Called when a customer toggles SFTPGo ingestion on/off in the portal:
* Enable → create a random password, create an SFTPGo user with home
dir mapped to cdr-uploads/{customer_id}/raw/sftpgo/ on the
MinIO backend, write the password hash + username back to
cdr_ingestion_profiles (sftpgo_username / sftpgo_password_hash).
Return the plaintext password to the portal ONCE so the
customer can copy it.
* Rotate → generate a new password, PUT it via the API.
* Disable → delete the user; customer can no longer connect.
Admin API reference: https://sftpgo.github.io/openapi/
"""
from __future__ import annotations
import argparse
import hashlib
import json
import logging
import os
import secrets
import sys
import urllib.request
import urllib.error
from typing import Optional
import psycopg2
import psycopg2.extras
log = logging.getLogger("cdr_sftpgo_provisioner")
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
handlers=[logging.StreamHandler(sys.stdout)],
)
SFTPGO_ADMIN_URL = os.environ.get("SFTPGO_ADMIN_URL", "http://sftpgo:8080")
SFTPGO_ADMIN_USER = os.environ.get("SFTPGO_ADMIN_USER", "pw-admin")
SFTPGO_ADMIN_PASSWORD = os.environ.get("SFTPGO_ADMIN_PASSWORD", "")
DATABASE_URL = os.environ.get("DATABASE_URL", "")
# ─── Token acquisition ────────────────────────────────────────────────────
_CACHED_TOKEN: Optional[str] = None
def _get_token() -> str:
global _CACHED_TOKEN
if _CACHED_TOKEN:
return _CACHED_TOKEN
import base64
creds = base64.b64encode(
f"{SFTPGO_ADMIN_USER}:{SFTPGO_ADMIN_PASSWORD}".encode("utf-8")
).decode("ascii")
req = urllib.request.Request(
f"{SFTPGO_ADMIN_URL}/api/v2/token",
headers={"Authorization": f"Basic {creds}"},
)
with urllib.request.urlopen(req, timeout=10) as resp:
payload = json.loads(resp.read())
_CACHED_TOKEN = payload.get("access_token")
if not _CACHED_TOKEN:
raise RuntimeError("SFTPGo admin token acquisition failed")
return _CACHED_TOKEN
def _api(method: str, path: str, body: Optional[dict] = None,
ok_statuses: tuple[int, ...] = (200, 201, 204)) -> dict:
token = _get_token()
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(
f"{SFTPGO_ADMIN_URL}{path}",
data=data,
method=method,
headers={"Authorization": f"Bearer {token}",
"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
if resp.status not in ok_statuses:
raise RuntimeError(f"unexpected status {resp.status}")
raw = resp.read()
return json.loads(raw) if raw else {}
except urllib.error.HTTPError as exc:
raise RuntimeError(f"SFTPGo API {method} {path} → HTTP {exc.code}: {exc.reason}") from exc
# ─── User provisioning ────────────────────────────────────────────────────
def _username_for(profile_id: int, customer_id: int) -> str:
return f"pw-{customer_id}-{profile_id}"
def _home_path(customer_id: int) -> str:
# Virtual path inside the MinIO bucket. SFTPGo translates this to
# the configured s3 filesystem's key prefix.
return f"/cdr-uploads/{customer_id}/raw/sftpgo"
def enable_user(profile_id: int, quota_bytes: Optional[int] = None) -> dict:
"""Create or rotate the SFTPGo user for this profile.
Returns {"username": ..., "password": ...}. Password shown ONCE —
store the bcrypt hash only; we can't recover it afterwards.
"""
conn = psycopg2.connect(DATABASE_URL)
try:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(
"SELECT id, customer_id, sftpgo_username "
"FROM cdr_ingestion_profiles WHERE id=%s",
(profile_id,),
)
profile = cur.fetchone()
if not profile:
raise RuntimeError(f"profile {profile_id} not found")
username = profile["sftpgo_username"] or _username_for(
profile_id, profile["customer_id"],
)
password = secrets.token_urlsafe(24)
quota = quota_bytes or 5 * 1024**3
body = {
"username": username,
"status": 1,
"password": password,
"home_dir": _home_path(profile["customer_id"]),
"filesystem": {"provider": 1, "name": "minio_default"}, # 1 = S3
"quota_size": quota,
"permissions": {"/": ["list", "upload", "download", "rename"]},
"description": f"PW CDR push profile {profile_id}",
}
# Try create; fall back to update on 409
try:
_api("POST", "/api/v2/users", body=body, ok_statuses=(201,))
action = "created"
except RuntimeError:
_api("PUT", f"/api/v2/users/{username}", body=body, ok_statuses=(200,))
action = "rotated"
# Record hash (not plaintext) on the profile
pwhash = hashlib.sha256(password.encode("utf-8")).hexdigest()
with conn.cursor() as cur:
cur.execute(
"UPDATE cdr_ingestion_profiles SET "
"sftpgo_enabled=TRUE, sftpgo_username=%s, "
"sftpgo_password_hash=%s, sftpgo_quota_bytes=%s WHERE id=%s",
(username, pwhash, quota, profile_id),
)
conn.commit()
log.info("sftpgo %s user %s for profile %s", action, username, profile_id)
return {"username": username, "password": password, "action": action}
finally:
conn.close()
def disable_user(profile_id: int) -> dict:
conn = psycopg2.connect(DATABASE_URL)
try:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(
"SELECT sftpgo_username FROM cdr_ingestion_profiles WHERE id=%s",
(profile_id,),
)
row = cur.fetchone()
if not row or not row["sftpgo_username"]:
return {"disabled": False, "reason": "no user on profile"}
username = row["sftpgo_username"]
try:
_api("DELETE", f"/api/v2/users/{username}", ok_statuses=(200, 204, 404))
except Exception as exc:
log.warning("delete user %s failed: %s", username, exc)
with conn.cursor() as cur:
cur.execute(
"UPDATE cdr_ingestion_profiles SET "
"sftpgo_enabled=FALSE, sftpgo_password_hash=NULL WHERE id=%s",
(profile_id,),
)
conn.commit()
return {"disabled": True, "username": username}
finally:
conn.close()
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("action", choices=["enable", "disable"])
parser.add_argument("--profile-id", type=int, required=True)
parser.add_argument("--quota-gb", type=int)
args = parser.parse_args()
if args.action == "enable":
quota_bytes = args.quota_gb * 1024**3 if args.quota_gb else None
print(enable_user(args.profile_id, quota_bytes=quota_bytes))
else:
print(disable_user(args.profile_id))
if __name__ == "__main__":
main()