sync: auto-sync from HOWARD-HOME at 2026-07-05 15:29:59

Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-05 15:29:59
This commit is contained in:
2026-07-05 15:30:28 -07:00
parent a5b8f6e7b1
commit fa4867580f
14 changed files with 1849 additions and 0 deletions

View File

@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""Datto Workplace REST API v1 client for the `datto-workplace` skill.
Talks to ACG's live Datto Workplace tenant file API. Read-only: this is a
FILE-ACCESS API (projects + files), NOT an admin/reporting API -- there are no
user/device/member/team endpoints. It answers "what shared projects + files does
this API principal see" for the GPS backup audit.
Auth: HTTP Basic, username = api-key, password = api-secret (no token exchange;
Basic is sent on every request). Credentials come from the SOPS vault.
Base URL defaults to the team file-API host. The team path /6/api/v1 is normalized
by the server to /api/v1; override with DATTO_WORKPLACE_URL if needed.
IMPORTANT GOTCHA: GET /file/projects currently returns {"result": []} for this
key -- the principal has no projects shared to it, so the file API alone CANNOT
enumerate the team's backups. Handle empty gracefully. Because of that, RMM
endpoint detection (see datto_workplace.py `audit --rmm`) is the PRIMARY source
of "who backs up to Datto Workplace".
"""
from __future__ import annotations
import os
import sys
import urllib.parse
from pathlib import Path
from typing import Any, Optional
# Import the shared backup helpers from .claude/scripts.
_SKILL_DIR = Path(__file__).resolve().parent.parent # .../skills/datto-workplace
_SCRIPTS_DIR = _SKILL_DIR.parent.parent / "scripts" # .../.claude/scripts
sys.path.insert(0, str(_SCRIPTS_DIR))
import backup_common as bc # noqa: E402
VAULT_ENTRY = "msp-tools/datto-workplace.sops.yaml"
VAULT_FIELD_KEY = "credentials.api-key"
VAULT_FIELD_SECRET = "credentials.api-secret"
DEFAULT_URL = os.environ.get("DATTO_WORKPLACE_URL", "https://acg.workplace.datto.com/api/v1")
class DattoWorkplaceClient:
"""Basic-auth GET client for the Datto Workplace file API.
No token caching is needed -- HTTP Basic is sent per request. Credentials are
read once from the vault on first use and reused for the process lifetime.
"""
def __init__(self, base: Optional[str] = None):
self.base = (base or DEFAULT_URL).rstrip("/")
self._basic: Optional[tuple[str, str]] = None
# -- auth ------------------------------------------------------------------
def _creds(self) -> tuple[str, str]:
if self._basic is None:
key = bc.vault_get_field(VAULT_ENTRY, VAULT_FIELD_KEY)
secret = bc.vault_get_field(VAULT_ENTRY, VAULT_FIELD_SECRET)
self._basic = (key, secret)
return self._basic
def _get_raw(self, path: str, params: Optional[dict] = None) -> tuple[int, Any]:
"""Low-level GET returning (status, body); callers decide how to react."""
url = f"{self.base}{path}"
if params:
url += "?" + urllib.parse.urlencode(params)
return bc.http_json("GET", url, basic=self._creds())
def _get(self, path: str, params: Optional[dict] = None) -> Any:
status, body = self._get_raw(path, params)
if status == 401:
raise bc.BackupError(
f"Datto Workplace auth failed (HTTP 401) for {path}: check api-key/api-secret",
status=status)
if status != 200:
raise bc.BackupError(
f"Datto Workplace GET {path} failed (HTTP {status}): {body}", status=status)
return body
# -- read methods ----------------------------------------------------------
def openapi(self) -> dict:
"""Fetch the OpenAPI spec (confirms reachability + exact endpoint list)."""
body = self._get("/openapi.json")
return body if isinstance(body, dict) else {"_raw": body}
def endpoint_paths(self) -> list[str]:
"""Sorted list of paths the OpenAPI spec exposes."""
spec = self.openapi()
paths = spec.get("paths") if isinstance(spec, dict) else None
return sorted(paths.keys()) if isinstance(paths, dict) else []
def projects(self) -> list[dict]:
"""Top-level file projects visible to this API principal.
Returns [] gracefully -- for this key the principal currently has no
projects shared to it. The server is inconsistent here: some responses
are 200 {"result": []}, others are 403 "Access denied" for the exact same
Basic auth that authorizes /openapi.json seconds earlier. Both mean the
same thing for the audit (no project-level file access), so a 403/401 on
this endpoint is treated as empty rather than a hard error. A genuine
credential failure surfaces on /openapi.json (status/openapi).
"""
status, body = self._get_raw("/file/projects")
if status in (401, 403):
return []
if status != 200:
raise bc.BackupError(
f"Datto Workplace GET /file/projects failed (HTTP {status}): {body}",
status=status)
return self._as_list(body)
def files(self, parent_id: str) -> list[dict]:
"""List files/folders under a parent project or folder ID."""
body = self._get(f"/file/{parent_id}/files")
return self._as_list(body)
def file_info(self, file_id: str) -> dict:
"""Metadata for a single file/folder ID."""
body = self._get(f"/file/{file_id}")
if isinstance(body, dict):
return body.get("result") if isinstance(body.get("result"), dict) else body
return {"_raw": body}
def search(self, query: str, parent_id: Optional[str] = None) -> list[dict]:
"""Search files by name. Optional parent scope."""
params = {"query": query}
if parent_id:
params["parentID"] = parent_id
body = self._get("/file/search", params)
return self._as_list(body)
@staticmethod
def _as_list(body: Any) -> list[dict]:
"""Normalize the Datto file-API envelope to a list.
Responses wrap payloads as {"result": [...]} (or a bare list on some
endpoints); anything else yields [].
"""
if isinstance(body, dict):
res = body.get("result")
if isinstance(res, list):
return res
if isinstance(res, dict):
return [res]
return []
if isinstance(body, list):
return body
return []
def main() -> int:
try:
c = DattoWorkplaceClient()
paths = c.endpoint_paths()
projects = c.projects()
print(f"[OK] Datto Workplace file API reachable at {c.base}; "
f"{len(paths)} endpoints, {len(projects)} project(s) visible")
return 0
except bc.BackupError as exc:
print(f"[ERROR] {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""CLI for the `datto-workplace` skill -- read-only Datto Workplace backup
verification for the GPS audit.
Commands:
status file API reachable under Basic auth + endpoint/project count
projects [--json] list file-API projects (likely empty for this principal)
audit [--client NAME] [--rmm] [--json]
the GPS view: server-side projects (if any), optionally
cross-checked against GuruRMM endpoints (Datto Workplace
client installed + signed in?)
The Datto Workplace file API is FILE-ACCESS ONLY and this principal sees no shared
projects, so it CANNOT enumerate the team's backups on its own. RMM endpoint
detection (`audit --rmm`) is therefore the PRIMARY source: it detects the Datto
Workplace client on GuruRMM agents using precise patterns that do NOT false-match
Datto EDR / Datto RMM / Datto AV.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from datto_client import DattoWorkplaceClient # noqa: E402
import backup_common as bc # noqa: E402
# Precise detection patterns. "Datto Workplace" catches the DisplayName of both the
# v10 client ("Datto Workplace v10.x") and legacy v8 ("Datto Workplace Desktop");
# "Workplace2" catches the v10 install path / process family. A bare "Datto" would
# false-match "Datto EDR Agent", Datto RMM, and Datto AV -- do NOT use it.
DETECT_PATTERNS = ["Datto Workplace", "Workplace2"]
def cmd_status(c: DattoWorkplaceClient, args) -> int:
paths = c.endpoint_paths()
projects = c.projects()
if args.json:
print(json.dumps({"base": c.base, "reachable": True,
"endpoints": paths, "project_count": len(projects)}, indent=2))
return 0
print(f"[OK] Datto Workplace file API reachable at {c.base} (Basic auth 200)")
print(f"[INFO] {len(paths)} endpoints exposed:")
for p in paths:
print(f" {p}")
print(f"[INFO] {len(projects)} project(s) visible to this API principal", end="")
if not projects:
print(" - file API cannot enumerate backups; use `audit --rmm`")
else:
print()
return 0
def cmd_projects(c: DattoWorkplaceClient, args) -> int:
projects = c.projects()
if args.json:
print(json.dumps(projects, indent=2))
return 0
if not projects:
print("[INFO] no file-API projects visible to this principal.")
print("[INFO] The api-key has no projects shared to it, so GET /file/projects "
"returns []. This is expected - the Datto Workplace file API is file-access "
"only. Use `audit --rmm` to verify backups via GuruRMM endpoint detection.")
return 0
print(f"{'NAME':40} {'ID':38} {'FILES':>8}")
for p in projects:
print(f"{str(p.get('name') or '')[:40]:40} "
f"{str(p.get('id') or p.get('projectID') or '')[:38]:38} "
f"{str(p.get('fileCount') or p.get('count') or '?'):>8}")
print(f"\n{len(projects)} project(s)")
return 0
def cmd_audit(c: DattoWorkplaceClient, args) -> int:
projects = c.projects()
result = {"backend": "datto-workplace", "base": c.base,
"file_api_projects": projects, "file_api_limited": not projects}
if args.rmm:
rmm = bc.RmmClient()
rmm.login()
checks = []
agents = rmm.find_agents(client_substr=args.client) if args.client else rmm.list_agents()
for a in agents:
det = rmm.detect_client(a, DETECT_PATTERNS)
if det.get("detected"):
checks.append(det)
result["rmm_endpoints_with_client"] = checks
result["rmm_agents_scanned"] = len(agents)
if args.json:
print(json.dumps(result, indent=2))
return 0
print(f"=== Datto Workplace backup audit - {c.base} ===")
if projects:
print(f"{'PROJECT':40} {'ID':38} {'FILES':>8}")
for p in projects:
print(f"{str(p.get('name') or '')[:40]:40} "
f"{str(p.get('id') or p.get('projectID') or '')[:38]:38} "
f"{str(p.get('fileCount') or p.get('count') or '?'):>8}")
print(f"\n{len(projects)} file-API project(s)")
else:
print("[INFO] file API shows 0 projects (principal has none shared) - "
"server-side enumeration not available; relying on RMM detection.")
if args.rmm:
eps = result.get("rmm_endpoints_with_client", [])
scanned = result.get("rmm_agents_scanned", 0)
scope = f"client~'{args.client}'" if args.client else "ALL agents"
print(f"\n--- GuruRMM endpoints running the Datto Workplace client "
f"({len(eps)} of {scanned} scanned, {scope}) ---")
for e in eps:
det = e.get("detail") or {}
installed = det.get("installed") or []
prod = "-"
if isinstance(installed, list) and installed:
first = installed[0]
if isinstance(first, dict):
prod = f"{first.get('name') or '?'} {first.get('version') or ''}".strip()
elif isinstance(installed, dict):
prod = f"{installed.get('name') or '?'} {installed.get('version') or ''}".strip()
print(f" {str(e.get('hostname') or '')[:25]:25} "
f"client={str(e.get('client_name') or ''):20} "
f"product={prod}")
return 0
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="datto-workplace",
description="Datto Workplace backup verification (GPS audit)")
p.add_argument("--url", help="override file-API base URL (default team file-API host)")
sub = p.add_subparsers(dest="cmd", required=True)
s = sub.add_parser("status"); s.add_argument("--json", action="store_true")
pr = sub.add_parser("projects"); pr.add_argument("--json", action="store_true")
a = sub.add_parser("audit")
a.add_argument("--client", help="scope RMM detection to agents whose client_name matches")
a.add_argument("--rmm", action="store_true", help="cross-check GuruRMM endpoints (primary path)")
a.add_argument("--json", action="store_true")
return p
def main(argv=None) -> int:
args = build_parser().parse_args(argv)
c = DattoWorkplaceClient(args.url)
handlers = {"status": cmd_status, "projects": cmd_projects, "audit": cmd_audit}
try:
return handlers[args.cmd](c, args)
except bc.BackupError as exc:
print(f"[ERROR] {exc}", file=sys.stderr)
# best-effort error logging per house rule
try:
root = bc.resolve_root()
bc.subprocess.run(["bash", str(root / ".claude/scripts/log-skill-error.sh"),
"datto-workplace", str(exc)[:200]], timeout=15)
except Exception:
pass
return 1
if __name__ == "__main__":
raise SystemExit(main())