trustarc-cli 1.0.0.dev20260929192430__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.
- ta/__init__.py +1 -0
- ta/commands/__init__.py +0 -0
- ta/commands/auth/__init__.py +1 -0
- ta/commands/auth/command.py +83 -0
- ta/commands/config/__init__.py +1 -0
- ta/commands/config/command.py +49 -0
- ta/commands/self_update/__init__.py +2 -0
- ta/commands/self_update/command.py +36 -0
- ta/commands/services/__init__.py +1 -0
- ta/commands/services/command.py +31 -0
- ta/lib/__init__.py +0 -0
- ta/lib/analytics.py +63 -0
- ta/lib/auth.py +212 -0
- ta/lib/auth_servers.py +56 -0
- ta/lib/catalog_cache.py +68 -0
- ta/lib/config.py +49 -0
- ta/lib/dynamic.py +230 -0
- ta/lib/services.py +13 -0
- ta/lib/spec_cache.py +25 -0
- ta/lib/update_check.py +66 -0
- ta/lib/urls.py +18 -0
- ta/main.py +47 -0
- trustarc_cli-1.0.0.dev20260929192430.dist-info/METADATA +100 -0
- trustarc_cli-1.0.0.dev20260929192430.dist-info/RECORD +31 -0
- trustarc_cli-1.0.0.dev20260929192430.dist-info/WHEEL +4 -0
- trustarc_cli-1.0.0.dev20260929192430.dist-info/entry_points.txt +2 -0
- trustarc_sdk/openapi/__init__.py +39 -0
- trustarc_sdk/openapi/call.py +204 -0
- trustarc_sdk/openapi/feature_manager.py +106 -0
- trustarc_sdk/openapi/parse.py +207 -0
- trustarc_sdk/openapi/schema.py +92 -0
ta/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0.dev20260929192430"
|
ta/commands/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from ta.commands.auth.command import app as app
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import typer
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
|
|
7
|
+
from ta.lib.auth import auth_file, load_tokens, login, logout
|
|
8
|
+
from ta.lib.auth_servers import AUTH_BASE_URLS
|
|
9
|
+
from ta.lib.config import load_config
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(
|
|
12
|
+
help=f"Manage authentication with the identity provider (env: {load_config()['env']}).",
|
|
13
|
+
no_args_is_help=True,
|
|
14
|
+
)
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _resolve_env(env: str | None) -> str:
|
|
19
|
+
if not env:
|
|
20
|
+
return load_config()["env"]
|
|
21
|
+
if env not in AUTH_BASE_URLS:
|
|
22
|
+
console.print(f"[red]Unknown env: {env}[/red] Valid: {', '.join(AUTH_BASE_URLS)}")
|
|
23
|
+
raise typer.Exit(1)
|
|
24
|
+
return env
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.command("login")
|
|
28
|
+
def login_cmd(
|
|
29
|
+
env: str = typer.Option(None, "--env", "-e", help="Target env (defaults to current config env)"),
|
|
30
|
+
):
|
|
31
|
+
"""Log in via browser (OAuth Authorization Code + PKCE)."""
|
|
32
|
+
env = _resolve_env(env)
|
|
33
|
+
try:
|
|
34
|
+
tokens = login(env)
|
|
35
|
+
except RuntimeError as e:
|
|
36
|
+
console.print(f"[red]Login failed:[/red] {e}")
|
|
37
|
+
raise typer.Exit(1) from e
|
|
38
|
+
expires = tokens.get("expires_at")
|
|
39
|
+
exp_str = f" (expires in {expires - int(time.time())}s)" if expires else ""
|
|
40
|
+
console.print(f"[green]Logged in to {env}[/green]{exp_str}")
|
|
41
|
+
console.print(f"[dim]Saved to {auth_file(env)}[/dim]")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.command("logout")
|
|
45
|
+
def logout_cmd(
|
|
46
|
+
env: str = typer.Option(None, "--env", "-e", help="Target env (defaults to current config env)"),
|
|
47
|
+
):
|
|
48
|
+
"""Revoke and clear saved tokens."""
|
|
49
|
+
env = _resolve_env(env)
|
|
50
|
+
logout(env)
|
|
51
|
+
console.print(f"[green]Logged out of {env}[/green]")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@app.command("status")
|
|
55
|
+
def status_cmd(
|
|
56
|
+
env: str = typer.Option(None, "--env", "-e", help="Target env (defaults to current config env)"),
|
|
57
|
+
):
|
|
58
|
+
"""Show authentication status."""
|
|
59
|
+
env = _resolve_env(env)
|
|
60
|
+
tokens = load_tokens(env)
|
|
61
|
+
if not tokens:
|
|
62
|
+
console.print(f"[yellow]Not logged in to {env}[/yellow]")
|
|
63
|
+
raise typer.Exit(1)
|
|
64
|
+
|
|
65
|
+
now = int(time.time())
|
|
66
|
+
remaining = tokens.get("expires_at", 0) - now
|
|
67
|
+
console.print(f"[bold]Env:[/bold] {env}")
|
|
68
|
+
if remaining > 0:
|
|
69
|
+
console.print(f"[green]Token valid[/green] ({remaining}s remaining)")
|
|
70
|
+
else:
|
|
71
|
+
console.print("[yellow]Token expired[/yellow] — `ta auth login` or it will auto-refresh on next use")
|
|
72
|
+
|
|
73
|
+
access = tokens.get("access_token")
|
|
74
|
+
userinfo_url = (tokens.get("oauth") or {}).get("userinfo_endpoint")
|
|
75
|
+
if userinfo_url and access:
|
|
76
|
+
try:
|
|
77
|
+
r = httpx.get(userinfo_url, headers={"Authorization": f"Bearer {access}"}, timeout=10)
|
|
78
|
+
if r.status_code == 200:
|
|
79
|
+
info = r.json()
|
|
80
|
+
email = info.get("email") or info.get("sub")
|
|
81
|
+
console.print(f"[bold]User:[/bold] {email}")
|
|
82
|
+
except httpx.HTTPError:
|
|
83
|
+
pass
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from ta.commands.config.command import app as app
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
|
|
4
|
+
from ta.lib.auth import load_tokens
|
|
5
|
+
from ta.lib.config import CONFIG_FILE, load_config, save_config
|
|
6
|
+
from ta.lib.services import ENVS
|
|
7
|
+
|
|
8
|
+
app = typer.Typer(help="Manage ta configuration.", no_args_is_help=True)
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.command("show")
|
|
13
|
+
def show():
|
|
14
|
+
"""Show current configuration."""
|
|
15
|
+
cfg = load_config()
|
|
16
|
+
token_status = (
|
|
17
|
+
"[green]logged in[/green]" if load_tokens(cfg["env"]) else "[dim](not logged in — run `ta auth login`)[/dim]"
|
|
18
|
+
)
|
|
19
|
+
console.print(f"\n[bold]Env:[/bold] {cfg['env']}")
|
|
20
|
+
console.print(f"[bold]Auth:[/bold] {token_status}")
|
|
21
|
+
console.print("\n[bold]Services:[/bold]")
|
|
22
|
+
for name, spec_url in sorted(cfg["services"].items()):
|
|
23
|
+
console.print(f" {name}: {spec_url}")
|
|
24
|
+
console.print()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.command("setup")
|
|
28
|
+
def setup():
|
|
29
|
+
"""Set up or update config file."""
|
|
30
|
+
current = load_config()
|
|
31
|
+
|
|
32
|
+
console.print("\nConfiguration (press Enter to keep current):\n")
|
|
33
|
+
env = typer.prompt(f" env ({'/'.join(ENVS)})", default=current["env"])
|
|
34
|
+
if env not in ENVS:
|
|
35
|
+
console.print(f"[red]Unknown env: {env}[/red]")
|
|
36
|
+
raise typer.Exit(1)
|
|
37
|
+
|
|
38
|
+
save_config({"env": env})
|
|
39
|
+
console.print(f"\n[green]Saved {CONFIG_FILE}[/green]")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@app.command("reset")
|
|
43
|
+
def reset_config():
|
|
44
|
+
"""Delete config file and use defaults."""
|
|
45
|
+
if CONFIG_FILE.exists():
|
|
46
|
+
CONFIG_FILE.unlink()
|
|
47
|
+
console.print(f"[green]Deleted {CONFIG_FILE}[/green]")
|
|
48
|
+
else:
|
|
49
|
+
console.print("[dim]No config file to delete[/dim]")
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from ta import __version__
|
|
8
|
+
from ta.lib.update_check import PACKAGE_NAME, is_newer, latest_version
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def upgrade_command(prefix: str = sys.prefix) -> list[str]:
|
|
12
|
+
parts = Path(prefix).parts
|
|
13
|
+
if "pipx" in parts:
|
|
14
|
+
return ["pipx", "upgrade", PACKAGE_NAME]
|
|
15
|
+
if "uv" in parts and "tools" in parts:
|
|
16
|
+
return ["uv", "tool", "upgrade", PACKAGE_NAME]
|
|
17
|
+
return [sys.executable, "-m", "pip", "install", "--upgrade", PACKAGE_NAME]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def self_update():
|
|
21
|
+
"""Update ta to the latest version."""
|
|
22
|
+
latest = latest_version(force=True)
|
|
23
|
+
if latest and not is_newer(latest):
|
|
24
|
+
typer.echo(f"Already up to date ({__version__})")
|
|
25
|
+
raise typer.Exit()
|
|
26
|
+
|
|
27
|
+
if latest:
|
|
28
|
+
typer.echo(f"Updating {__version__} → {latest}...")
|
|
29
|
+
else:
|
|
30
|
+
typer.echo("Checking for updates...")
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
result = subprocess.run(upgrade_command())
|
|
34
|
+
except FileNotFoundError:
|
|
35
|
+
result = subprocess.run([sys.executable, "-m", "pip", "install", "--upgrade", PACKAGE_NAME])
|
|
36
|
+
raise typer.Exit(result.returncode)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from ta.commands.services.command import services as services
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from rich.table import Table
|
|
4
|
+
|
|
5
|
+
from ta.lib.config import load_config
|
|
6
|
+
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def services(
|
|
11
|
+
refresh: bool = typer.Option(False, "--refresh", help="Re-fetch the service catalog from the server."),
|
|
12
|
+
):
|
|
13
|
+
"""List configured services."""
|
|
14
|
+
cfg = load_config(refresh_catalog=refresh)
|
|
15
|
+
if not cfg["services"]:
|
|
16
|
+
console.print(
|
|
17
|
+
f"[yellow]No services available for env '{cfg['env']}'. "
|
|
18
|
+
f"The service catalog could not be reached — check connectivity and try again.[/yellow]"
|
|
19
|
+
)
|
|
20
|
+
return
|
|
21
|
+
if refresh:
|
|
22
|
+
console.print("[green]Catalog refreshed.[/green]")
|
|
23
|
+
catalog = cfg["catalog"]
|
|
24
|
+
table = Table(title="Services", title_style="bold", show_lines=False, header_style="bold cyan")
|
|
25
|
+
table.add_column("Code", style="bold")
|
|
26
|
+
table.add_column("Name", style="dim")
|
|
27
|
+
table.add_column("Description")
|
|
28
|
+
for name in sorted(cfg["services"]):
|
|
29
|
+
entry = catalog[name]
|
|
30
|
+
table.add_row(name, entry.get("name", ""), entry.get("description", ""))
|
|
31
|
+
console.print(table)
|
ta/lib/__init__.py
ADDED
|
File without changes
|
ta/lib/analytics.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from ta.lib.auth_servers import ARC_API_URLS
|
|
10
|
+
|
|
11
|
+
_TIMEOUT_S = 10.0
|
|
12
|
+
_WORKER = [sys.executable, "-I", "-m", "ta.lib.analytics"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def report_tool_call(
|
|
16
|
+
env: str,
|
|
17
|
+
token: str,
|
|
18
|
+
app: str,
|
|
19
|
+
resource: str,
|
|
20
|
+
action: str,
|
|
21
|
+
method: str,
|
|
22
|
+
status_code: int | None,
|
|
23
|
+
error_type: str | None = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
base_url = ARC_API_URLS.get(env)
|
|
26
|
+
if os.getenv("DO_NOT_TRACK") or not base_url or not token:
|
|
27
|
+
return
|
|
28
|
+
report = {
|
|
29
|
+
"url": f"{base_url}/api/analytics/tool-calls",
|
|
30
|
+
"token": token,
|
|
31
|
+
"payload": {
|
|
32
|
+
"app": app,
|
|
33
|
+
"resource": resource,
|
|
34
|
+
"action": action,
|
|
35
|
+
"method": method,
|
|
36
|
+
"status_code": status_code,
|
|
37
|
+
"error_type": error_type,
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
with contextlib.suppress(OSError):
|
|
41
|
+
process = subprocess.Popen(
|
|
42
|
+
_WORKER,
|
|
43
|
+
stdin=subprocess.PIPE,
|
|
44
|
+
stdout=subprocess.DEVNULL,
|
|
45
|
+
stderr=subprocess.DEVNULL,
|
|
46
|
+
start_new_session=True,
|
|
47
|
+
)
|
|
48
|
+
process.stdin.write(json.dumps(report).encode())
|
|
49
|
+
process.stdin.close()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _send(report: dict) -> None:
|
|
53
|
+
with contextlib.suppress(httpx.HTTPError):
|
|
54
|
+
httpx.post(
|
|
55
|
+
report["url"],
|
|
56
|
+
json=report["payload"],
|
|
57
|
+
headers={"Authorization": f"Bearer {report['token']}"},
|
|
58
|
+
timeout=_TIMEOUT_S,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
_send(json.load(sys.stdin))
|
ta/lib/auth.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import hashlib
|
|
3
|
+
import http.server
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import secrets
|
|
7
|
+
import socket
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
import webbrowser
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from urllib.parse import parse_qs, urlencode, urlparse
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from ta.lib.auth_servers import auth_server_for
|
|
17
|
+
from ta.lib.config import CONFIG_DIR
|
|
18
|
+
|
|
19
|
+
AUTH_DIR = CONFIG_DIR / "auth"
|
|
20
|
+
REFRESH_LEEWAY_SECONDS = 60
|
|
21
|
+
CALLBACK_PORT = 8765
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def auth_file(env: str) -> Path:
|
|
25
|
+
return AUTH_DIR / f"{env}.json"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_tokens(env: str) -> dict | None:
|
|
29
|
+
path = auth_file(env)
|
|
30
|
+
if not path.exists():
|
|
31
|
+
return None
|
|
32
|
+
with open(path) as f:
|
|
33
|
+
return json.load(f)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def save_tokens(env: str, tokens: dict) -> None:
|
|
37
|
+
AUTH_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
38
|
+
AUTH_DIR.chmod(0o700)
|
|
39
|
+
path = auth_file(env)
|
|
40
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
41
|
+
path.chmod(0o600)
|
|
42
|
+
with os.fdopen(fd, "w") as f:
|
|
43
|
+
json.dump(tokens, f, indent=2)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def clear_tokens(env: str) -> None:
|
|
47
|
+
path = auth_file(env)
|
|
48
|
+
if path.exists():
|
|
49
|
+
path.unlink()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def login(env: str) -> dict:
|
|
53
|
+
port = CALLBACK_PORT
|
|
54
|
+
verifier, challenge = _pkce()
|
|
55
|
+
state = secrets.token_urlsafe(16)
|
|
56
|
+
|
|
57
|
+
received: dict[str, str | None] = {"code": None, "state": None, "error": None}
|
|
58
|
+
done = threading.Event()
|
|
59
|
+
|
|
60
|
+
class Handler(http.server.BaseHTTPRequestHandler):
|
|
61
|
+
def do_GET(self):
|
|
62
|
+
if self.path.startswith("/callback"):
|
|
63
|
+
qs = parse_qs(urlparse(self.path).query)
|
|
64
|
+
received["code"] = qs.get("code", [None])[0]
|
|
65
|
+
received["state"] = qs.get("state", [None])[0]
|
|
66
|
+
received["error"] = qs.get("error", [None])[0]
|
|
67
|
+
self.send_response(200)
|
|
68
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
69
|
+
self.end_headers()
|
|
70
|
+
body = (
|
|
71
|
+
"Login successful. You can close this window."
|
|
72
|
+
if received["code"]
|
|
73
|
+
else f"Login failed: {received['error']}"
|
|
74
|
+
)
|
|
75
|
+
self.wfile.write(f"<html><body><h1>{body}</h1></body></html>".encode())
|
|
76
|
+
done.set()
|
|
77
|
+
else:
|
|
78
|
+
self.send_response(404)
|
|
79
|
+
self.end_headers()
|
|
80
|
+
|
|
81
|
+
def log_message(self, *_):
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
server, redirect_uri = _serve_callback(Handler, port)
|
|
85
|
+
cfg = auth_server_for(env, redirect_uri)
|
|
86
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
87
|
+
thread.start()
|
|
88
|
+
|
|
89
|
+
params = {
|
|
90
|
+
"client_id": cfg["client_id"],
|
|
91
|
+
"response_type": "code",
|
|
92
|
+
"redirect_uri": redirect_uri,
|
|
93
|
+
"scope": cfg.get("scope", "openid"),
|
|
94
|
+
"state": state,
|
|
95
|
+
"code_challenge": challenge,
|
|
96
|
+
"code_challenge_method": "S256",
|
|
97
|
+
}
|
|
98
|
+
auth_url = f"{cfg['authorization_endpoint']}?{urlencode(params)}"
|
|
99
|
+
|
|
100
|
+
print(f"Opening browser to log in ({env})...")
|
|
101
|
+
print(f"If it doesn't open, visit:\n {auth_url}\n")
|
|
102
|
+
webbrowser.open(auth_url)
|
|
103
|
+
|
|
104
|
+
if not done.wait(timeout=300):
|
|
105
|
+
server.shutdown()
|
|
106
|
+
raise RuntimeError("Login timed out")
|
|
107
|
+
server.shutdown()
|
|
108
|
+
|
|
109
|
+
if received["error"]:
|
|
110
|
+
raise RuntimeError(f"Authorization error: {received['error']}")
|
|
111
|
+
if not received["code"]:
|
|
112
|
+
raise RuntimeError("No authorization code received")
|
|
113
|
+
if received["state"] != state:
|
|
114
|
+
raise RuntimeError("State mismatch — possible CSRF")
|
|
115
|
+
|
|
116
|
+
data = {
|
|
117
|
+
"grant_type": "authorization_code",
|
|
118
|
+
"code": received["code"],
|
|
119
|
+
"redirect_uri": redirect_uri,
|
|
120
|
+
"client_id": cfg["client_id"],
|
|
121
|
+
"code_verifier": verifier,
|
|
122
|
+
}
|
|
123
|
+
resp = httpx.post(cfg["token_endpoint"], data=data, timeout=30)
|
|
124
|
+
if resp.status_code != 200:
|
|
125
|
+
raise RuntimeError(f"Token exchange failed: {resp.status_code} {resp.text}")
|
|
126
|
+
|
|
127
|
+
tokens = resp.json()
|
|
128
|
+
tokens["saved_at"] = int(time.time())
|
|
129
|
+
if "expires_in" in tokens:
|
|
130
|
+
tokens["expires_at"] = tokens["saved_at"] + int(tokens["expires_in"])
|
|
131
|
+
tokens["oauth"] = {
|
|
132
|
+
"client_id": cfg["client_id"],
|
|
133
|
+
"token_endpoint": cfg["token_endpoint"],
|
|
134
|
+
"revocation_endpoint": cfg.get("revocation_endpoint"),
|
|
135
|
+
"userinfo_endpoint": cfg.get("userinfo_endpoint"),
|
|
136
|
+
}
|
|
137
|
+
save_tokens(env, tokens)
|
|
138
|
+
return tokens
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def refresh(env: str) -> dict | None:
|
|
142
|
+
tokens = load_tokens(env)
|
|
143
|
+
if not tokens or not tokens.get("refresh_token"):
|
|
144
|
+
return None
|
|
145
|
+
oauth = tokens.get("oauth") or {}
|
|
146
|
+
token_endpoint = oauth.get("token_endpoint")
|
|
147
|
+
client_id = oauth.get("client_id")
|
|
148
|
+
if not token_endpoint or not client_id:
|
|
149
|
+
return None
|
|
150
|
+
data = {
|
|
151
|
+
"grant_type": "refresh_token",
|
|
152
|
+
"refresh_token": tokens["refresh_token"],
|
|
153
|
+
"client_id": client_id,
|
|
154
|
+
}
|
|
155
|
+
resp = httpx.post(token_endpoint, data=data, timeout=30)
|
|
156
|
+
if resp.status_code != 200:
|
|
157
|
+
return None
|
|
158
|
+
new_tokens = resp.json()
|
|
159
|
+
new_tokens["saved_at"] = int(time.time())
|
|
160
|
+
if "expires_in" in new_tokens:
|
|
161
|
+
new_tokens["expires_at"] = new_tokens["saved_at"] + int(new_tokens["expires_in"])
|
|
162
|
+
if "refresh_token" not in new_tokens and tokens.get("refresh_token"):
|
|
163
|
+
new_tokens["refresh_token"] = tokens["refresh_token"]
|
|
164
|
+
new_tokens["oauth"] = oauth
|
|
165
|
+
save_tokens(env, new_tokens)
|
|
166
|
+
return new_tokens
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def get_access_token(env: str) -> str | None:
|
|
170
|
+
tokens = load_tokens(env)
|
|
171
|
+
if not tokens:
|
|
172
|
+
return None
|
|
173
|
+
if "expires_at" in tokens and tokens["expires_at"] - time.time() < REFRESH_LEEWAY_SECONDS:
|
|
174
|
+
refreshed = refresh(env)
|
|
175
|
+
if refreshed:
|
|
176
|
+
tokens = refreshed
|
|
177
|
+
return tokens.get("access_token")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def logout(env: str) -> None:
|
|
181
|
+
tokens = load_tokens(env)
|
|
182
|
+
if tokens:
|
|
183
|
+
oauth = tokens.get("oauth") or {}
|
|
184
|
+
revoke = oauth.get("revocation_endpoint")
|
|
185
|
+
client_id = oauth.get("client_id")
|
|
186
|
+
if revoke and client_id and tokens.get("refresh_token"):
|
|
187
|
+
try:
|
|
188
|
+
httpx.post(
|
|
189
|
+
revoke,
|
|
190
|
+
data={"token": tokens["refresh_token"], "client_id": client_id},
|
|
191
|
+
timeout=10,
|
|
192
|
+
)
|
|
193
|
+
except httpx.HTTPError:
|
|
194
|
+
pass
|
|
195
|
+
clear_tokens(env)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class _IPv6Server(http.server.HTTPServer):
|
|
199
|
+
address_family = socket.AF_INET6
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _serve_callback(handler: type, port: int) -> tuple[http.server.HTTPServer, str]:
|
|
203
|
+
try:
|
|
204
|
+
return _IPv6Server(("::1", port), handler), f"http://[::1]:{port}/callback"
|
|
205
|
+
except OSError:
|
|
206
|
+
return http.server.HTTPServer(("127.0.0.1", port), handler), f"http://127.0.0.1:{port}/callback"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _pkce() -> tuple[str, str]:
|
|
210
|
+
verifier = secrets.token_urlsafe(64)
|
|
211
|
+
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
|
|
212
|
+
return verifier, challenge
|
ta/lib/auth_servers.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
|
|
3
|
+
ARC_API_URLS: dict[str, str] = {
|
|
4
|
+
"dev": "https://arc.dev.trustarc.com",
|
|
5
|
+
"qa": "https://arc.qa.trustarc.com",
|
|
6
|
+
"prod-us": "https://arc.trustarc.com",
|
|
7
|
+
"prod-eu": "https://arc.trustarc.eu",
|
|
8
|
+
"prod-india": "https://arc.trustarc.in",
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
AUTH_BASE_URLS: dict[str, str] = {env: f"{url}/api/mcp" for env, url in ARC_API_URLS.items()}
|
|
12
|
+
|
|
13
|
+
FEATURE_MANAGER_API_URLS: dict[str, str] = {
|
|
14
|
+
"dev": "https://feature.dev.trustarc.com",
|
|
15
|
+
"qa": "https://feature.qa.trustarc.com",
|
|
16
|
+
"prod-us": "https://feature.trustarc.com",
|
|
17
|
+
"prod-eu": "https://feature.trustarc.eu",
|
|
18
|
+
"prod-india": "https://feature.trustarc.in",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
SCOPE = "openid"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def discover(env: str) -> dict:
|
|
25
|
+
base = AUTH_BASE_URLS.get(env)
|
|
26
|
+
if not base:
|
|
27
|
+
raise RuntimeError(f"No auth base URL configured for env '{env}'")
|
|
28
|
+
oauth_resp = httpx.get(f"{base}/.well-known/oauth-authorization-server", timeout=30)
|
|
29
|
+
oauth_resp.raise_for_status()
|
|
30
|
+
oauth_md = oauth_resp.json()
|
|
31
|
+
oidc_resp = httpx.get(f"{base}/.well-known/openid-configuration", timeout=30)
|
|
32
|
+
oidc_md = oidc_resp.json() if oidc_resp.status_code == 200 else {}
|
|
33
|
+
return {
|
|
34
|
+
"registration_endpoint": oauth_md["registration_endpoint"],
|
|
35
|
+
"authorization_endpoint": oauth_md["authorization_endpoint"],
|
|
36
|
+
"token_endpoint": oauth_md["token_endpoint"],
|
|
37
|
+
"revocation_endpoint": oauth_md.get("revocation_endpoint"),
|
|
38
|
+
"userinfo_endpoint": oidc_md.get("userinfo_endpoint"),
|
|
39
|
+
"scope": SCOPE,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def register(registration_endpoint: str, redirect_uri: str) -> str:
|
|
44
|
+
resp = httpx.post(
|
|
45
|
+
registration_endpoint,
|
|
46
|
+
json={"redirect_uris": [redirect_uri]},
|
|
47
|
+
timeout=30,
|
|
48
|
+
)
|
|
49
|
+
resp.raise_for_status()
|
|
50
|
+
return resp.json()["client_id"]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def auth_server_for(env: str, redirect_uri: str) -> dict:
|
|
54
|
+
cfg = discover(env)
|
|
55
|
+
cfg["client_id"] = register(cfg["registration_endpoint"], redirect_uri)
|
|
56
|
+
return cfg
|
ta/lib/catalog_cache.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from ta.lib.auth_servers import ARC_API_URLS
|
|
8
|
+
from ta.lib.urls import to_public_url
|
|
9
|
+
|
|
10
|
+
CATALOG_DIR = Path.home() / ".ta" / "catalog"
|
|
11
|
+
CATALOG_CACHE_TTL_SECONDS = 600
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _catalog_cache_path(env: str) -> Path:
|
|
15
|
+
return CATALOG_DIR / f"{env}.json"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _read_cache(path: Path) -> dict | None:
|
|
19
|
+
if not path.exists():
|
|
20
|
+
return None
|
|
21
|
+
with open(path) as f:
|
|
22
|
+
return json.load(f)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _fetch_catalog(env: str) -> dict:
|
|
26
|
+
base = ARC_API_URLS.get(env)
|
|
27
|
+
if not base:
|
|
28
|
+
raise RuntimeError(f"No ARC API URL configured for env '{env}'")
|
|
29
|
+
resp = httpx.get(f"{base}/api/mcp/services", timeout=30)
|
|
30
|
+
resp.raise_for_status()
|
|
31
|
+
catalog: dict[str, dict] = {}
|
|
32
|
+
for entry in resp.json().get("services", []):
|
|
33
|
+
catalog[entry["key"]] = {
|
|
34
|
+
"name": entry.get("name", ""),
|
|
35
|
+
"description": entry.get("description", ""),
|
|
36
|
+
"base_url": to_public_url(entry.get("base_url")),
|
|
37
|
+
"spec_path": entry["spec_path"],
|
|
38
|
+
"prefix": entry["prefix"],
|
|
39
|
+
"include_tags": entry["include_tags"],
|
|
40
|
+
}
|
|
41
|
+
return catalog
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def evict_catalog(env: str) -> None:
|
|
45
|
+
_catalog_cache_path(env).unlink(missing_ok=True)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_catalog(env: str, *, force: bool = False) -> dict:
|
|
49
|
+
path = _catalog_cache_path(env)
|
|
50
|
+
if not force and path.exists() and (time.time() - path.stat().st_mtime) < CATALOG_CACHE_TTL_SECONDS:
|
|
51
|
+
cached = _read_cache(path)
|
|
52
|
+
if cached is not None:
|
|
53
|
+
return cached
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
catalog = _fetch_catalog(env)
|
|
57
|
+
except httpx.HTTPError as e:
|
|
58
|
+
stale = _read_cache(path)
|
|
59
|
+
if stale is not None:
|
|
60
|
+
return stale
|
|
61
|
+
raise RuntimeError(
|
|
62
|
+
f"Could not fetch service catalog for env '{env}' and no cached copy is available: {e}"
|
|
63
|
+
) from e
|
|
64
|
+
|
|
65
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
with open(path, "w") as f:
|
|
67
|
+
json.dump(catalog, f)
|
|
68
|
+
return catalog
|
ta/lib/config.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from ta.lib.catalog_cache import load_catalog
|
|
5
|
+
from ta.lib.services import ENVS, services_for
|
|
6
|
+
|
|
7
|
+
CONFIG_DIR = Path.home() / ".ta"
|
|
8
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
9
|
+
|
|
10
|
+
DEFAULT_CONFIG = {
|
|
11
|
+
"env": "prod-us",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _deep_merge(base: dict, override: dict) -> dict:
|
|
16
|
+
result = base.copy()
|
|
17
|
+
for key, value in override.items():
|
|
18
|
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
19
|
+
result[key] = _deep_merge(result[key], value)
|
|
20
|
+
else:
|
|
21
|
+
result[key] = value
|
|
22
|
+
return result
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_config(refresh_catalog: bool = False) -> dict:
|
|
26
|
+
if CONFIG_FILE.exists():
|
|
27
|
+
with open(CONFIG_FILE) as f:
|
|
28
|
+
user_config = json.load(f)
|
|
29
|
+
cfg = _deep_merge(DEFAULT_CONFIG, user_config)
|
|
30
|
+
else:
|
|
31
|
+
cfg = dict(DEFAULT_CONFIG)
|
|
32
|
+
|
|
33
|
+
if cfg["env"] not in ENVS:
|
|
34
|
+
raise ValueError(f"Unknown env: {cfg['env']}. Valid: {', '.join(ENVS)}")
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
catalog = load_catalog(cfg["env"], force=refresh_catalog)
|
|
38
|
+
except RuntimeError:
|
|
39
|
+
catalog = {}
|
|
40
|
+
cfg["catalog"] = catalog
|
|
41
|
+
cfg["services"] = services_for(catalog)
|
|
42
|
+
return cfg
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def save_config(config: dict) -> Path:
|
|
46
|
+
CONFIG_DIR.mkdir(exist_ok=True)
|
|
47
|
+
with open(CONFIG_FILE, "w") as f:
|
|
48
|
+
json.dump(config, f, indent=2)
|
|
49
|
+
return CONFIG_FILE
|