workspaceguard-cli 0.1.1__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.
- workspaceguard/__init__.py +78 -0
- workspaceguard/adapters/__init__.py +3 -0
- workspaceguard/adapters/mock.py +39 -0
- workspaceguard/circuit_breaker.py +65 -0
- workspaceguard/cli.py +188 -0
- workspaceguard/config.py +156 -0
- workspaceguard/isolation_guard.py +158 -0
- workspaceguard/log.py +43 -0
- workspaceguard/namespace.py +21 -0
- workspaceguard/py.typed +0 -0
- workspaceguard/types.py +78 -0
- workspaceguard/usage.py +149 -0
- workspaceguard/vault.py +130 -0
- workspaceguard_cli-0.1.1.dist-info/METADATA +226 -0
- workspaceguard_cli-0.1.1.dist-info/RECORD +18 -0
- workspaceguard_cli-0.1.1.dist-info/WHEEL +4 -0
- workspaceguard_cli-0.1.1.dist-info/entry_points.txt +2 -0
- workspaceguard_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Programmatic / agent-native entry point.
|
|
3
|
+
|
|
4
|
+
from workspaceguard import create_workspace_guard, MockAdapter
|
|
5
|
+
|
|
6
|
+
guard = await create_workspace_guard(data_dir="./data", backend=MockAdapter())
|
|
7
|
+
await guard.add_workspace("alex", "alex@example.com")
|
|
8
|
+
await guard.set_cap("alex", 1000)
|
|
9
|
+
report = await guard.usage_report()
|
|
10
|
+
|
|
11
|
+
Same core isolation/metering logic as the `workspaceguard` CLI;
|
|
12
|
+
workspaceguard/cli.py is a thin argument-parsing wrapper over this class.
|
|
13
|
+
|
|
14
|
+
This is the Python port of the workspaceguard-cli npm package
|
|
15
|
+
(https://www.npmjs.com/package/workspaceguard-cli, not yet published as of
|
|
16
|
+
this port -- see README for current npm status). See
|
|
17
|
+
https://github.com/RudrenduPaul/workspaceguard for the canonical
|
|
18
|
+
documentation and the original TypeScript source.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
from .adapters.mock import MockAdapter
|
|
25
|
+
from .config import DuplicateIdentityError, InvalidWorkspaceIdError
|
|
26
|
+
from .isolation_guard import IsolationGuard, WorkspaceUsageReport
|
|
27
|
+
from .types import (
|
|
28
|
+
BackendAdapter,
|
|
29
|
+
BackendCircuitOpenError,
|
|
30
|
+
BackendUnreachableError,
|
|
31
|
+
IdentityNotFoundError,
|
|
32
|
+
VaultDecryptionError,
|
|
33
|
+
WorkspaceNotFoundError,
|
|
34
|
+
)
|
|
35
|
+
from .usage import QuotaExceededError, WorkspaceUsage
|
|
36
|
+
|
|
37
|
+
__version__ = "0.1.1"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def create_workspace_guard(
|
|
41
|
+
data_dir: str,
|
|
42
|
+
backend: BackendAdapter,
|
|
43
|
+
circuit_open_cooldown_ms: Optional[int] = None,
|
|
44
|
+
) -> IsolationGuard:
|
|
45
|
+
"""
|
|
46
|
+
The library entry point -- the "agent-native" surface. An orchestrator
|
|
47
|
+
process can call this directly instead of shelling out to the CLI. No
|
|
48
|
+
default backend -- a caller must be explicit about which adapter it
|
|
49
|
+
wants (the real Odysseus adapter ships once the feasibility spike
|
|
50
|
+
confirms clean HTTP interception; MockAdapter is exported for tests and
|
|
51
|
+
local experimentation).
|
|
52
|
+
"""
|
|
53
|
+
guard = IsolationGuard(
|
|
54
|
+
data_dir=data_dir,
|
|
55
|
+
backend=backend,
|
|
56
|
+
circuit_open_cooldown_ms=circuit_open_cooldown_ms,
|
|
57
|
+
)
|
|
58
|
+
await guard.init()
|
|
59
|
+
return guard
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
__all__ = [
|
|
63
|
+
"create_workspace_guard",
|
|
64
|
+
"IsolationGuard",
|
|
65
|
+
"WorkspaceUsageReport",
|
|
66
|
+
"BackendAdapter",
|
|
67
|
+
"MockAdapter",
|
|
68
|
+
"IdentityNotFoundError",
|
|
69
|
+
"VaultDecryptionError",
|
|
70
|
+
"BackendUnreachableError",
|
|
71
|
+
"BackendCircuitOpenError",
|
|
72
|
+
"WorkspaceNotFoundError",
|
|
73
|
+
"DuplicateIdentityError",
|
|
74
|
+
"InvalidWorkspaceIdError",
|
|
75
|
+
"QuotaExceededError",
|
|
76
|
+
"WorkspaceUsage",
|
|
77
|
+
"__version__",
|
|
78
|
+
]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""
|
|
2
|
+
In-memory mock backend, used for the scaffold-stage isolation tests only.
|
|
3
|
+
Ported from src/adapters/mock.ts. The real Odysseus adapter is blocked on a
|
|
4
|
+
feasibility spike: does Odysseus's HTTP API expose clean interception
|
|
5
|
+
points, or does it read/write some of this directly from disk/DB in a way
|
|
6
|
+
an HTTP-level proxy can't see? See docs/integrations/backends.md.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Dict, List
|
|
11
|
+
|
|
12
|
+
from ..types import BackendAdapter
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MockAdapter(BackendAdapter):
|
|
16
|
+
name = "mock"
|
|
17
|
+
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self._messages_by_workspace: Dict[str, List[str]] = {}
|
|
20
|
+
self._failures_remaining = 0
|
|
21
|
+
|
|
22
|
+
async def health_check(self) -> bool:
|
|
23
|
+
return True
|
|
24
|
+
|
|
25
|
+
async def forward_chat(self, workspace_id: str, message: str) -> str:
|
|
26
|
+
if self._failures_remaining > 0:
|
|
27
|
+
self._failures_remaining -= 1
|
|
28
|
+
raise RuntimeError("simulated backend failure")
|
|
29
|
+
existing = self._messages_by_workspace.setdefault(workspace_id, [])
|
|
30
|
+
existing.append(message)
|
|
31
|
+
return f"echo: {message}"
|
|
32
|
+
|
|
33
|
+
def _fail_next_calls(self, n: int) -> None:
|
|
34
|
+
"""Test-only: makes the next N forward_chat calls fail, to exercise the circuit breaker."""
|
|
35
|
+
self._failures_remaining = n
|
|
36
|
+
|
|
37
|
+
def _messages_for(self, workspace_id: str) -> List[str]:
|
|
38
|
+
"""Test-only inspection point -- never exposed through the public adapter interface."""
|
|
39
|
+
return self._messages_by_workspace.get(workspace_id, [])
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Fail closed, never pass-through-unisolated -- the worst failure mode here is
|
|
3
|
+
a silent cross-workspace leak, so an unreachable backend must block, not
|
|
4
|
+
bypass, isolation. Ported from src/core/circuit-breaker.ts.
|
|
5
|
+
|
|
6
|
+
Opens after 3 consecutive failures. Once the cooldown has passed since
|
|
7
|
+
opening, the next call is let through as a half-open probe; success closes
|
|
8
|
+
the circuit, failure keeps it open and restarts the cooldown -- a real
|
|
9
|
+
circuit breaker with a path back to closed, not a one-way trip switch.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import time
|
|
15
|
+
from typing import Awaitable, Callable, TypeVar
|
|
16
|
+
|
|
17
|
+
from .log import CircuitStateChangeEvent, Logger
|
|
18
|
+
from .types import BackendCircuitOpenError, BackendUnreachableError
|
|
19
|
+
|
|
20
|
+
T = TypeVar("T")
|
|
21
|
+
|
|
22
|
+
FAILURE_THRESHOLD = 3
|
|
23
|
+
CONNECT_TIMEOUT_MS = 3000
|
|
24
|
+
DEFAULT_OPEN_COOLDOWN_MS = 10_000
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CircuitBreaker:
|
|
28
|
+
def __init__(self, backend_name: str, logger: Logger, open_cooldown_ms: int = DEFAULT_OPEN_COOLDOWN_MS) -> None:
|
|
29
|
+
self._backend_name = backend_name
|
|
30
|
+
self._logger = logger
|
|
31
|
+
self._open_cooldown_ms = open_cooldown_ms
|
|
32
|
+
self._consecutive_failures = 0
|
|
33
|
+
self._open = False
|
|
34
|
+
self._opened_at = 0.0
|
|
35
|
+
|
|
36
|
+
def _probe_allowed(self) -> bool:
|
|
37
|
+
return self._open and (time.monotonic() * 1000 - self._opened_at) >= self._open_cooldown_ms
|
|
38
|
+
|
|
39
|
+
async def call(self, fn: Callable[[], Awaitable[T]]) -> T:
|
|
40
|
+
if self._open and not self._probe_allowed():
|
|
41
|
+
raise BackendCircuitOpenError(self._backend_name)
|
|
42
|
+
try:
|
|
43
|
+
result = await asyncio.wait_for(fn(), timeout=CONNECT_TIMEOUT_MS / 1000)
|
|
44
|
+
self._consecutive_failures = 0
|
|
45
|
+
self.on_health_check_success()
|
|
46
|
+
return result
|
|
47
|
+
except Exception:
|
|
48
|
+
self._consecutive_failures += 1
|
|
49
|
+
if self._consecutive_failures >= FAILURE_THRESHOLD:
|
|
50
|
+
self._open_circuit()
|
|
51
|
+
raise BackendUnreachableError(self._backend_name)
|
|
52
|
+
|
|
53
|
+
def _open_circuit(self) -> None:
|
|
54
|
+
was_open = self._open
|
|
55
|
+
self._open = True
|
|
56
|
+
self._opened_at = time.monotonic() * 1000
|
|
57
|
+
if not was_open:
|
|
58
|
+
self._logger.log(CircuitStateChangeEvent(backend=self._backend_name, state="open"))
|
|
59
|
+
|
|
60
|
+
def on_health_check_success(self) -> None:
|
|
61
|
+
"""A health check success (or a successful half-open probe) closes the circuit again."""
|
|
62
|
+
if self._open:
|
|
63
|
+
self._open = False
|
|
64
|
+
self._consecutive_failures = 0
|
|
65
|
+
self._logger.log(CircuitStateChangeEvent(backend=self._backend_name, state="closed"))
|
workspaceguard/cli.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Console entry point: `workspaceguard <command> [args] [--json]`, installed
|
|
4
|
+
via the `workspaceguard` console-script defined in python/pyproject.toml --
|
|
5
|
+
the same command name as the (not-yet-published) npm package's `bin` entry.
|
|
6
|
+
|
|
7
|
+
Ported from src/cli/index.ts. Commands, flags, human-readable text, and
|
|
8
|
+
--json output shapes are kept identical to the TypeScript CLI so the two
|
|
9
|
+
distributions are drop-in equivalents for anything that shells out to
|
|
10
|
+
either one.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
from dataclasses import asdict
|
|
18
|
+
from typing import Any, List, Optional, Tuple
|
|
19
|
+
|
|
20
|
+
from . import create_workspace_guard
|
|
21
|
+
from .adapters.mock import MockAdapter
|
|
22
|
+
|
|
23
|
+
STARTUP_WARNING = (
|
|
24
|
+
"WARNING: workspaceguard must never be directly reachable from the network.\n"
|
|
25
|
+
"It trusts an upstream identity header (e.g. Cf-Access-Authenticated-User-Email)\n"
|
|
26
|
+
"set by your reverse proxy. If this port is exposed without that proxy in front\n"
|
|
27
|
+
"of it, anyone can impersonate any workspace. Firewall this port; only your\n"
|
|
28
|
+
"trusted proxy should be able to reach it."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
USAGE = "usage: workspaceguard <init|add-workspace|status|rotate-key|usage|set-cap|scan> [--json]"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _extract_json_flag(args: List[str]) -> Tuple[bool, List[str]]:
|
|
35
|
+
"""Strips a boolean flag out of an argv slice, agent-native mode toggle for every command."""
|
|
36
|
+
rest = [a for a in args if a != "--json"]
|
|
37
|
+
return len(rest) != len(args), rest
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _print_result(json_mode: bool, data: Any, human_lines: List[str]) -> None:
|
|
41
|
+
if json_mode:
|
|
42
|
+
import json as _json
|
|
43
|
+
|
|
44
|
+
print(_json.dumps(data))
|
|
45
|
+
return
|
|
46
|
+
for line in human_lines:
|
|
47
|
+
print(line)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _usage_report_to_dict(report: Any) -> dict:
|
|
51
|
+
d = asdict(report)
|
|
52
|
+
return {
|
|
53
|
+
"workspaceId": d["workspace_id"],
|
|
54
|
+
"identity": d["identity"],
|
|
55
|
+
"monthlyMessageCap": d["monthly_message_cap"],
|
|
56
|
+
"percentUsed": d["percent_used"],
|
|
57
|
+
"period": d["period"],
|
|
58
|
+
"messageCount": d["message_count"],
|
|
59
|
+
"estimatedBytes": d["estimated_bytes"],
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def _run(argv: List[str]) -> int:
|
|
64
|
+
command = argv[1] if len(argv) > 1 else None
|
|
65
|
+
raw_args = argv[2:]
|
|
66
|
+
json_mode, args = _extract_json_flag(raw_args)
|
|
67
|
+
data_dir = os.environ.get("WORKSPACEGUARD_DATA_DIR") or os.getcwd()
|
|
68
|
+
|
|
69
|
+
if command == "init":
|
|
70
|
+
if not json_mode:
|
|
71
|
+
print(STARTUP_WARNING)
|
|
72
|
+
guard = await create_workspace_guard(data_dir=data_dir, backend=MockAdapter())
|
|
73
|
+
workspaces = await guard.status()
|
|
74
|
+
_print_result(json_mode, {"ok": True, "dataDir": data_dir, "workspaces": workspaces}, [
|
|
75
|
+
f"workspaceguard initialized in {data_dir}"
|
|
76
|
+
])
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
if command == "add-workspace":
|
|
80
|
+
workspace_id: Optional[str] = args[0] if len(args) > 0 else None
|
|
81
|
+
identity: Optional[str] = args[2] if len(args) > 2 else None # add-workspace <id> --identity <value>
|
|
82
|
+
if not workspace_id or not identity:
|
|
83
|
+
_print_result(
|
|
84
|
+
json_mode,
|
|
85
|
+
{"ok": False, "error": "usage: workspaceguard add-workspace <id> --identity <value>"},
|
|
86
|
+
["usage: workspaceguard add-workspace <id> --identity <value>"],
|
|
87
|
+
)
|
|
88
|
+
return 1
|
|
89
|
+
guard = await create_workspace_guard(data_dir=data_dir, backend=MockAdapter())
|
|
90
|
+
await guard.add_workspace(workspace_id, identity)
|
|
91
|
+
_print_result(json_mode, {"ok": True, "workspaceId": workspace_id, "identity": identity}, [
|
|
92
|
+
f"workspace {workspace_id} added"
|
|
93
|
+
])
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
if command == "status":
|
|
97
|
+
guard = await create_workspace_guard(data_dir=data_dir, backend=MockAdapter())
|
|
98
|
+
workspaces = await guard.status()
|
|
99
|
+
if workspaces:
|
|
100
|
+
lines = [f"-> {w['workspaceId']} [isolated] identity: {w['identity']}" for w in workspaces]
|
|
101
|
+
lines.append("No cross-workspace leaks detected.")
|
|
102
|
+
else:
|
|
103
|
+
lines = ["no workspaces configured"]
|
|
104
|
+
_print_result(json_mode, {"ok": True, "workspaces": workspaces}, lines)
|
|
105
|
+
return 0
|
|
106
|
+
|
|
107
|
+
if command == "rotate-key":
|
|
108
|
+
workspace_id = args[0] if len(args) > 0 else None
|
|
109
|
+
if not workspace_id:
|
|
110
|
+
_print_result(json_mode, {"ok": False, "error": "usage: workspaceguard rotate-key <id>"}, [
|
|
111
|
+
"usage: workspaceguard rotate-key <id>"
|
|
112
|
+
])
|
|
113
|
+
return 1
|
|
114
|
+
guard = await create_workspace_guard(data_dir=data_dir, backend=MockAdapter())
|
|
115
|
+
await guard.rotate_key(workspace_id)
|
|
116
|
+
_print_result(json_mode, {"ok": True, "workspaceId": workspace_id}, [f"workspace {workspace_id} key rotated"])
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
if command == "usage":
|
|
120
|
+
guard = await create_workspace_guard(data_dir=data_dir, backend=MockAdapter())
|
|
121
|
+
report = await guard.usage_report()
|
|
122
|
+
report_dicts = [_usage_report_to_dict(r) for r in report]
|
|
123
|
+
if report:
|
|
124
|
+
lines = []
|
|
125
|
+
for r in report:
|
|
126
|
+
cap = str(r.monthly_message_cap) if r.monthly_message_cap is not None else "unlimited"
|
|
127
|
+
pct = f" ({r.percent_used}%)" if r.percent_used is not None else ""
|
|
128
|
+
lines.append(
|
|
129
|
+
f"-> {r.workspace_id} [{r.identity}]: {r.message_count} messages this period, cap {cap}{pct}"
|
|
130
|
+
)
|
|
131
|
+
else:
|
|
132
|
+
lines = ["no workspaces configured"]
|
|
133
|
+
_print_result(json_mode, {"ok": True, "usage": report_dicts}, lines)
|
|
134
|
+
return 0
|
|
135
|
+
|
|
136
|
+
if command == "set-cap":
|
|
137
|
+
workspace_id = args[0] if len(args) > 0 else None
|
|
138
|
+
cap_arg = args[1] if len(args) > 1 else None
|
|
139
|
+
if not workspace_id or not cap_arg:
|
|
140
|
+
_print_result(
|
|
141
|
+
json_mode,
|
|
142
|
+
{"ok": False, "error": "usage: workspaceguard set-cap <workspaceId> <count|none>"},
|
|
143
|
+
["usage: workspaceguard set-cap <workspaceId> <count|none>"],
|
|
144
|
+
)
|
|
145
|
+
return 1
|
|
146
|
+
cap: Optional[int]
|
|
147
|
+
if cap_arg == "none":
|
|
148
|
+
cap = None
|
|
149
|
+
else:
|
|
150
|
+
try:
|
|
151
|
+
cap = int(cap_arg)
|
|
152
|
+
if cap < 0:
|
|
153
|
+
raise ValueError
|
|
154
|
+
except ValueError:
|
|
155
|
+
_print_result(json_mode, {"ok": False, "error": f"invalid cap value: {cap_arg}"}, [
|
|
156
|
+
f'invalid cap value: {cap_arg} (must be a non-negative integer or "none")'
|
|
157
|
+
])
|
|
158
|
+
return 1
|
|
159
|
+
guard = await create_workspace_guard(data_dir=data_dir, backend=MockAdapter())
|
|
160
|
+
await guard.set_cap(workspace_id, cap)
|
|
161
|
+
_print_result(json_mode, {"ok": True, "workspaceId": workspace_id, "cap": cap}, [
|
|
162
|
+
f"workspace {workspace_id} cap cleared (unlimited)"
|
|
163
|
+
if cap is None
|
|
164
|
+
else f"workspace {workspace_id} cap set to {cap} messages/month"
|
|
165
|
+
])
|
|
166
|
+
return 0
|
|
167
|
+
|
|
168
|
+
if command == "scan":
|
|
169
|
+
_print_result(json_mode, {"ok": True, "findings": []}, [
|
|
170
|
+
"isolation config scan: no misconfigurations detected (scaffold stub)"
|
|
171
|
+
])
|
|
172
|
+
return 0
|
|
173
|
+
|
|
174
|
+
_print_result(json_mode, {"ok": False, "error": USAGE}, [USAGE])
|
|
175
|
+
return 1
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def main() -> None:
|
|
179
|
+
try:
|
|
180
|
+
code = asyncio.run(_run(sys.argv))
|
|
181
|
+
except Exception as err: # noqa: BLE001 -- top-level crash guard, mirrors src/cli/index.ts's catch-all
|
|
182
|
+
print(str(err), file=sys.stderr)
|
|
183
|
+
sys.exit(1)
|
|
184
|
+
sys.exit(code)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
if __name__ == "__main__":
|
|
188
|
+
main()
|
workspaceguard/config.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Load/save `workspaceguard.config.yaml` and the pure config-mutation helpers
|
|
3
|
+
(add a workspace, set a cap, resolve an identity to a workspace id). Ported
|
|
4
|
+
from src/core/config.ts. YAML keys are camelCase (backend, identityHeader,
|
|
5
|
+
workspaces: [{workspaceId, identity, monthlyMessageCap}]) to stay
|
|
6
|
+
file-compatible with the npm package's own config file.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
from .types import WorkspaceEntry, WorkspaceGuardConfig, WorkspaceNotFoundError
|
|
17
|
+
|
|
18
|
+
DEFAULT_IDENTITY_HEADER = "cf-access-authenticated-user-email"
|
|
19
|
+
|
|
20
|
+
# workspace_id is used to build filesystem paths (vault, namespace, chat
|
|
21
|
+
# history dirs) -- an allowlist plus a reserved-name denylist closes a
|
|
22
|
+
# path-traversal vector (e.g. "../../etc") before it ever reaches those
|
|
23
|
+
# call sites.
|
|
24
|
+
_WORKSPACE_ID_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+$")
|
|
25
|
+
_RESERVED_WORKSPACE_IDS = {"__proto__", "constructor", "prototype", ".", ".."}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InvalidWorkspaceIdError(Exception):
|
|
29
|
+
def __init__(self, workspace_id: str) -> None:
|
|
30
|
+
super().__init__(
|
|
31
|
+
f'workspace id "{workspace_id}" is invalid: must match {_WORKSPACE_ID_PATTERN.pattern} '
|
|
32
|
+
"and must not be a reserved name"
|
|
33
|
+
)
|
|
34
|
+
self.workspace_id = workspace_id
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _assert_valid_workspace_id(workspace_id: str) -> None:
|
|
38
|
+
if not _WORKSPACE_ID_PATTERN.match(workspace_id) or workspace_id in _RESERVED_WORKSPACE_IDS:
|
|
39
|
+
raise InvalidWorkspaceIdError(workspace_id)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _normalize_identity(identity: str) -> str:
|
|
43
|
+
"""
|
|
44
|
+
Identities are compared case/whitespace-insensitively so that
|
|
45
|
+
"Alex@Example.com " and "alex@example.com" resolve to the same
|
|
46
|
+
workspace -- without this, the duplicate-identity guard below could be
|
|
47
|
+
evaded by registering a near-duplicate, producing two workspaces (and
|
|
48
|
+
two independent quotas) for what is really one real-world identity.
|
|
49
|
+
The original casing is still stored, only comparisons are normalized.
|
|
50
|
+
"""
|
|
51
|
+
return identity.strip().lower()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _default_config() -> WorkspaceGuardConfig:
|
|
55
|
+
return WorkspaceGuardConfig(backend="odysseus", identity_header=DEFAULT_IDENTITY_HEADER, workspaces=[])
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_config(config_path: str) -> WorkspaceGuardConfig:
|
|
59
|
+
try:
|
|
60
|
+
with open(config_path, "r", encoding="utf-8") as fh:
|
|
61
|
+
raw = fh.read()
|
|
62
|
+
parsed = yaml.safe_load(raw) or {}
|
|
63
|
+
workspaces = [
|
|
64
|
+
WorkspaceEntry(
|
|
65
|
+
workspace_id=w["workspaceId"],
|
|
66
|
+
identity=w["identity"],
|
|
67
|
+
monthly_message_cap=w.get("monthlyMessageCap"),
|
|
68
|
+
)
|
|
69
|
+
for w in parsed.get("workspaces", [])
|
|
70
|
+
]
|
|
71
|
+
return WorkspaceGuardConfig(
|
|
72
|
+
backend=parsed.get("backend") or "odysseus",
|
|
73
|
+
identity_header=parsed.get("identityHeader") or DEFAULT_IDENTITY_HEADER,
|
|
74
|
+
workspaces=workspaces,
|
|
75
|
+
)
|
|
76
|
+
except Exception:
|
|
77
|
+
return _default_config()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def save_config(config_path: str, config: WorkspaceGuardConfig) -> None:
|
|
81
|
+
os.makedirs(os.path.dirname(config_path), exist_ok=True)
|
|
82
|
+
payload = {
|
|
83
|
+
"backend": config.backend,
|
|
84
|
+
"identityHeader": config.identity_header,
|
|
85
|
+
"workspaces": [
|
|
86
|
+
{
|
|
87
|
+
"workspaceId": w.workspace_id,
|
|
88
|
+
"identity": w.identity,
|
|
89
|
+
**({"monthlyMessageCap": w.monthly_message_cap} if w.monthly_message_cap is not None else {}),
|
|
90
|
+
}
|
|
91
|
+
for w in config.workspaces
|
|
92
|
+
],
|
|
93
|
+
}
|
|
94
|
+
with open(config_path, "w", encoding="utf-8") as fh:
|
|
95
|
+
yaml.safe_dump(payload, fh, sort_keys=False)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class DuplicateIdentityError(Exception):
|
|
99
|
+
def __init__(self, identity: str, existing_workspace_id: str) -> None:
|
|
100
|
+
super().__init__(f'identity "{identity}" is already assigned to workspace "{existing_workspace_id}"')
|
|
101
|
+
self.identity = identity
|
|
102
|
+
self.existing_workspace_id = existing_workspace_id
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def upsert_workspace(config: WorkspaceGuardConfig, workspace_id: str, identity: str) -> WorkspaceGuardConfig:
|
|
106
|
+
"""
|
|
107
|
+
add-workspace on an existing id is idempotent -- returns the existing
|
|
108
|
+
config unchanged, never overwrites. Rejects loudly if the identity is
|
|
109
|
+
already claimed by a DIFFERENT workspace id: without this check, two
|
|
110
|
+
workspace ids could silently share one identity, and resolve_workspace_id
|
|
111
|
+
would deterministically route to whichever was added first, merging two
|
|
112
|
+
workspaces from the routing layer's perspective without either operator
|
|
113
|
+
being told.
|
|
114
|
+
"""
|
|
115
|
+
for w in config.workspaces:
|
|
116
|
+
if w.workspace_id == workspace_id:
|
|
117
|
+
return config
|
|
118
|
+
_assert_valid_workspace_id(workspace_id)
|
|
119
|
+
normalized = _normalize_identity(identity)
|
|
120
|
+
for w in config.workspaces:
|
|
121
|
+
if _normalize_identity(w.identity) == normalized:
|
|
122
|
+
raise DuplicateIdentityError(identity, w.workspace_id)
|
|
123
|
+
return WorkspaceGuardConfig(
|
|
124
|
+
backend=config.backend,
|
|
125
|
+
identity_header=config.identity_header,
|
|
126
|
+
workspaces=[*config.workspaces, WorkspaceEntry(workspace_id=workspace_id, identity=identity)],
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def set_workspace_cap(
|
|
131
|
+
config: WorkspaceGuardConfig, workspace_id: str, cap: Optional[int]
|
|
132
|
+
) -> WorkspaceGuardConfig:
|
|
133
|
+
"""
|
|
134
|
+
Sets (or clears, with cap=None) a workspace's monthly message cap.
|
|
135
|
+
Rejects loudly on an unknown workspace id rather than silently
|
|
136
|
+
no-op'ing, matching upsert_workspace's fail-loud-on-ambiguity precedent.
|
|
137
|
+
"""
|
|
138
|
+
index = next((i for i, w in enumerate(config.workspaces) if w.workspace_id == workspace_id), None)
|
|
139
|
+
if index is None:
|
|
140
|
+
raise WorkspaceNotFoundError(workspace_id)
|
|
141
|
+
workspaces = list(config.workspaces)
|
|
142
|
+
existing = workspaces[index]
|
|
143
|
+
workspaces[index] = WorkspaceEntry(
|
|
144
|
+
workspace_id=existing.workspace_id, identity=existing.identity, monthly_message_cap=cap
|
|
145
|
+
)
|
|
146
|
+
return WorkspaceGuardConfig(backend=config.backend, identity_header=config.identity_header, workspaces=workspaces)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def resolve_workspace_id(config: WorkspaceGuardConfig, identity_header_value: Optional[str]) -> Optional[str]:
|
|
150
|
+
if not identity_header_value:
|
|
151
|
+
return None
|
|
152
|
+
normalized = _normalize_identity(identity_header_value)
|
|
153
|
+
for w in config.workspaces:
|
|
154
|
+
if _normalize_identity(w.identity) == normalized:
|
|
155
|
+
return w.workspace_id
|
|
156
|
+
return None
|