ecadlib 0.3.0__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.
- ecadlib/__init__.py +3 -0
- ecadlib/__main__.py +5 -0
- ecadlib/a365_sync.py +637 -0
- ecadlib/agent.py +1407 -0
- ecadlib/agent_queue.py +780 -0
- ecadlib/agent_watch.py +732 -0
- ecadlib/altium365.py +385 -0
- ecadlib/altium_dblib_sync.py +530 -0
- ecadlib/altium_export.py +209 -0
- ecadlib/altium_forms.py +768 -0
- ecadlib/altium_lib.py +1089 -0
- ecadlib/altium_menu.py +1751 -0
- ecadlib/altium_pkg.py +396 -0
- ecadlib/altium_project.py +1095 -0
- ecadlib/app.py +1458 -0
- ecadlib/approvals.py +395 -0
- ecadlib/assets/demo_bom.csv +8 -0
- ecadlib/assets/kicad_action_plugin.py +607 -0
- ecadlib/assets/kicad_reread.py +60 -0
- ecadlib/assets/pcblib_template.json.gz +0 -0
- ecadlib/bom_inputs.py +339 -0
- ecadlib/bom_triage.py +519 -0
- ecadlib/cad_model.py +528 -0
- ecadlib/cfb.py +171 -0
- ecadlib/cli.py +185 -0
- ecadlib/clihelp.py +149 -0
- ecadlib/cliutil.py +119 -0
- ecadlib/completion.py +173 -0
- ecadlib/costs.py +276 -0
- ecadlib/crosscheck.py +412 -0
- ecadlib/datasheet.py +2168 -0
- ecadlib/dedupe.py +572 -0
- ecadlib/detect.py +170 -0
- ecadlib/doctor.py +450 -0
- ecadlib/ds_route.py +541 -0
- ecadlib/eagle_export.py +1409 -0
- ecadlib/easy_setup.py +338 -0
- ecadlib/easyeda_convert.py +386 -0
- ecadlib/easyeda_pro_export.py +972 -0
- ecadlib/eval/__init__.py +0 -0
- ecadlib/eval/baselines/kicad-v1.json +404 -0
- ecadlib/eval/build_set.py +299 -0
- ecadlib/eval/cli.py +119 -0
- ecadlib/eval/gate.py +358 -0
- ecadlib/eval/golden/demo.txt +379 -0
- ecadlib/eval/golden/specs.txt +3120 -0
- ecadlib/eval/golden/testpack.txt +463 -0
- ecadlib/eval/golden.py +446 -0
- ecadlib/eval/replay/kicad-v1.json.gz +0 -0
- ecadlib/eval/runner.py +456 -0
- ecadlib/eval/score.py +179 -0
- ecadlib/eval/sets/kicad-v1.json +630 -0
- ecadlib/firstrun.py +400 -0
- ecadlib/footprint_adv.py +1006 -0
- ecadlib/footprint_bench.py +218 -0
- ecadlib/footprint_check.py +562 -0
- ecadlib/footprint_gen.py +582 -0
- ecadlib/footprint_rules.py +1092 -0
- ecadlib/forge.py +267 -0
- ecadlib/fp_share.py +95 -0
- ecadlib/fp_tidy.py +389 -0
- ecadlib/fsutil.py +62 -0
- ecadlib/house_style.py +1051 -0
- ecadlib/httpcache.py +124 -0
- ecadlib/hub.py +1490 -0
- ecadlib/hub_copy.py +161 -0
- ecadlib/hub_tools.py +79 -0
- ecadlib/kicad_dblib.py +257 -0
- ecadlib/kicad_export.py +837 -0
- ecadlib/kicad_import.py +913 -0
- ecadlib/kicad_index.py +160 -0
- ecadlib/kicad_menu.py +193 -0
- ecadlib/kicad_pcm.py +475 -0
- ecadlib/launcher.py +156 -0
- ecadlib/librarian.py +963 -0
- ecadlib/lifecycle.py +691 -0
- ecadlib/llm/__init__.py +276 -0
- ecadlib/llm/cli.py +70 -0
- ecadlib/llm/openai_compat.py +214 -0
- ecadlib/llm/profiles.py +245 -0
- ecadlib/llm/rails.py +570 -0
- ecadlib/llm/run.py +126 -0
- ecadlib/llm/sampling.py +49 -0
- ecadlib/mcp_server.py +723 -0
- ecadlib/messages.py +419 -0
- ecadlib/migrate.py +407 -0
- ecadlib/notify.py +329 -0
- ecadlib/package_schema.json +1238 -0
- ecadlib/package_specs/CP_EIA-3216-18_Kemet-A.json +27 -0
- ecadlib/package_specs/C_0402_1005Metric.json +22 -0
- ecadlib/package_specs/C_0805_2012Metric.json +22 -0
- ecadlib/package_specs/DFN-8-1EP_3x3mm_P0.5mm_EP1.66x2.38mm.json +40 -0
- ecadlib/package_specs/D_SMA.json +27 -0
- ecadlib/package_specs/D_SMB.json +27 -0
- ecadlib/package_specs/D_SOD-123.json +43 -0
- ecadlib/package_specs/D_SOD-323.json +43 -0
- ecadlib/package_specs/LED_0603_1608Metric.json +23 -0
- ecadlib/package_specs/LED_0805_2012Metric.json +23 -0
- ecadlib/package_specs/LQFP-48_7x7mm_P0.5mm.json +31 -0
- ecadlib/package_specs/L_0805_2012Metric.json +22 -0
- ecadlib/package_specs/MSOP-8_3x3mm_P0.65mm.json +31 -0
- ecadlib/package_specs/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm.json +39 -0
- ecadlib/package_specs/QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm.json +37 -0
- ecadlib/package_specs/R_0402_1005Metric.json +22 -0
- ecadlib/package_specs/R_0603_1608Metric.json +22 -0
- ecadlib/package_specs/R_0805_2012Metric.json +22 -0
- ecadlib/package_specs/R_1206_3216Metric.json +22 -0
- ecadlib/package_specs/SOIC-16_3.9x9.9mm_P1.27mm.json +32 -0
- ecadlib/package_specs/SOIC-8_3.9x4.9mm_P1.27mm.json +32 -0
- ecadlib/package_specs/SOT-223-3_TabPin2.json +56 -0
- ecadlib/package_specs/SOT-23-5.json +57 -0
- ecadlib/package_specs/SOT-23-6.json +62 -0
- ecadlib/package_specs/SOT-23.json +48 -0
- ecadlib/package_specs/TSSOP-16_4.4x5mm_P0.65mm.json +31 -0
- ecadlib/package_specs/VQFN-16-1EP_3x3mm_P0.5mm_EP1.68x1.68mm.json +39 -0
- ecadlib/package_specs/advanced/BGA-100_11.0x11.0mm_Layout10x10_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-121_9.0x9.0mm_Layout11x11_P0.8mm_Ball0.4mm_Pad0.35mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-144_7.0x7.0mm_Layout13x13_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +29 -0
- ecadlib/package_specs/advanced/BGA-169_11.0x11.0mm_Layout13x13_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +23 -0
- ecadlib/package_specs/advanced/BGA-256_11.0x11.0mm_Layout20x20_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-256_17.0x17.0mm_Layout16x16_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-324_15.0x15.0mm_Layout18x18_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-36_3.396x3.466mm_Layout6x6_P0.4mm_Ball0.25mm_Pad0.2mm_NSMD.json +24 -0
- ecadlib/package_specs/advanced/BGA-672_27.0x27.0mm_Layout26x26_P1.0mm_Ball0.6mm_Pad0.5mm_NSMD.json +32 -0
- ecadlib/package_specs/advanced/BGA-68_5.0x5.0mm_Layout9x9_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +30 -0
- ecadlib/package_specs/advanced/BGA-90_8.0x13.0mm_Layout2x3x15_P0.8mm.json +26 -0
- ecadlib/package_specs/advanced/DFN-8-1EP_3x3mm_P0.5mm_EP1.65x2.38mm_ThermalVias.json +60 -0
- ecadlib/package_specs/advanced/Example_Custom_LPad_Slot.json +24 -0
- ecadlib/package_specs/advanced/JST_GH_SM04B-GHS-TB_1x04-1MP_P1.25mm_Horizontal.json +49 -0
- ecadlib/package_specs/advanced/JST_PH_B4B-PH-K_1x04_P2.00mm_Vertical.json +33 -0
- ecadlib/package_specs/advanced/JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_Horizontal.json +55 -0
- ecadlib/package_specs/advanced/JST_SH_SM04B-SRSS-TB_1x04-1MP_P1.00mm_Horizontal.json +49 -0
- ecadlib/package_specs/advanced/JST_XH_B2B-XH-A_1x02_P2.50mm_Vertical.json +34 -0
- ecadlib/package_specs/advanced/JST_XH_B4B-XH-A_1x04_P2.50mm_Vertical.json +34 -0
- ecadlib/package_specs/advanced/JST_XH_B6B-XH-A_1x06_P2.50mm_Vertical.json +33 -0
- ecadlib/package_specs/advanced/LFBGA-144_10x10mm_Layout12x12_P0.8mm.json +23 -0
- ecadlib/package_specs/advanced/Molex_KK-254_AE-6410-04A_1x04_P2.54mm_Vertical.json +34 -0
- ecadlib/package_specs/advanced/Molex_PicoBlade_53398-0471_1x04-1MP_P1.25mm_Vertical.json +54 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P1.00mm_Vertical_SMD_Pin1Left.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P1.27mm_Vertical_SMD_Pin1Left.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical_SMD_Pin1Left.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Left.json +25 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Right.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x10_P1.00mm_Vertical.json +15 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P1.00mm_Vertical_SMD.json +13 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical.json +18 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical_SMD.json +24 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P2.00mm_Vertical_SMD.json +13 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical_SMD.json +24 -0
- ecadlib/package_specs/advanced/PinHeader_2x20_P2.54mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm_ThermalVias.json +87 -0
- ecadlib/package_specs/advanced/UFBGA-132_7x7mm_Layout12x12_P0.5mm.json +43 -0
- ecadlib/package_specs/advanced/USB_C_Receptacle_HRO_TYPE-C-31-M-12.json +218 -0
- ecadlib/package_specs/standard.json.gz +0 -0
- ecadlib/parity.py +1204 -0
- ecadlib/parts_export.py +154 -0
- ecadlib/partview.py +215 -0
- ecadlib/plugins.py +231 -0
- ecadlib/providers.py +226 -0
- ecadlib/rebuild.py +471 -0
- ecadlib/release_check.py +437 -0
- ecadlib/reports.py +274 -0
- ecadlib/resolve.py +556 -0
- ecadlib/review_cli.py +292 -0
- ecadlib/rules.py +979 -0
- ecadlib/rules_infer.py +1205 -0
- ecadlib/sexpr.py +149 -0
- ecadlib/sheetsafe.py +65 -0
- ecadlib/sim_models.py +1087 -0
- ecadlib/sourcing.py +1161 -0
- ecadlib/stdparts.py +755 -0
- ecadlib/step_gen.py +929 -0
- ecadlib/symbol_layout.py +798 -0
- ecadlib/team.py +558 -0
- ecadlib/team_setup.py +288 -0
- ecadlib/term.py +420 -0
- ecadlib/term_views.py +64 -0
- ecadlib/testpack.py +334 -0
- ecadlib/ui/__init__.py +1 -0
- ecadlib/ui/__main__.py +3 -0
- ecadlib/ui/server.py +1029 -0
- ecadlib/ui/static/hairline.js +2 -0
- ecadlib/ui/static/index.html +2098 -0
- ecadlib/ui/static/setup.html +294 -0
- ecadlib/upkeep.py +717 -0
- ecadlib/upstream.py +573 -0
- ecadlib/userconfig.py +257 -0
- ecadlib/visual_check.py +1603 -0
- ecadlib/ws_status.py +203 -0
- ecadlib-0.3.0.dist-info/METADATA +2245 -0
- ecadlib-0.3.0.dist-info/RECORD +199 -0
- ecadlib-0.3.0.dist-info/WHEEL +5 -0
- ecadlib-0.3.0.dist-info/entry_points.txt +2 -0
- ecadlib-0.3.0.dist-info/licenses/LICENSE +21 -0
- ecadlib-0.3.0.dist-info/licenses/NOTICE +53 -0
- ecadlib-0.3.0.dist-info/top_level.txt +1 -0
ecadlib/team.py
ADDED
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
"""Team mode for the web UI: users, roles, passwords, sessions and the team audit log. Standard library only.
|
|
2
|
+
|
|
3
|
+
ecadlib team add-user ann --role admin [--name "Ann Lee"] [--email ann@x.com] (asks for the password)
|
|
4
|
+
ecadlib team set-role bob librarian [--sign-as librarian,hardware]
|
|
5
|
+
ecadlib team reset-password bob [--password-stdin]
|
|
6
|
+
ecadlib team remove bob
|
|
7
|
+
ecadlib team list
|
|
8
|
+
ecadlib team setup [--domain D] [--admin NAME] Docker Compose + Caddy (HTTPS), first admin, start (team_setup.py)
|
|
9
|
+
(all take --workspace DIR, default ecadlib_workspace; then: ecadlib ui --team --host 0.0.0.0 --cert c.pem --key k.pem)
|
|
10
|
+
|
|
11
|
+
Files in the workspace (never served by /api/file):
|
|
12
|
+
team_users.json users: role, name, e-mail, scrypt password hash (per-user salt), generation counter
|
|
13
|
+
team.json optional settings (see CONFIG_DEFAULTS): allowed host names, trusted proxy, session lengths, lockout
|
|
14
|
+
team_audit.jsonl one line per write request in team mode: time, user, client address, route, result
|
|
15
|
+
|
|
16
|
+
Roles, lowest first; each includes the ones before it:
|
|
17
|
+
viewer reads everything in the workspace (reports, plans, approval status), downloads files
|
|
18
|
+
librarian uploads BOMs, triage, librarian, resolve, footprints, exports, release-check overrides, Altium 365 dry run
|
|
19
|
+
approver approves / rejects plan items (approvals.py), live Altium 365 push
|
|
20
|
+
admin users, library rules (ecadlib.toml), scanning folders on the server
|
|
21
|
+
The approval role recorded with a sign-off is the user's `sign_as` list (default: the team role); approvals.json
|
|
22
|
+
`users`/`roles` then work as before, keyed by the e-mail (or name) of the signed-in user."""
|
|
23
|
+
import argparse, base64, contextlib, datetime, getpass, hashlib, hmac, ipaddress, json, os, re, secrets, sys, threading, time
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
ROLES = ("viewer", "librarian", "approver", "admin")
|
|
27
|
+
RANK = {r: i for i, r in enumerate(ROLES)}
|
|
28
|
+
USERS_FILE, CONFIG_FILE, AUDIT_FILE, LOCK_FILE = "team_users.json", "team.json", "team_audit.jsonl", ".team.lock"
|
|
29
|
+
TEAM_FILES = (USERS_FILE, CONFIG_FILE, AUDIT_FILE, LOCK_FILE)
|
|
30
|
+
USERNAME = re.compile(r"^[a-z0-9][a-z0-9._@+-]{0,63}$")
|
|
31
|
+
MIN_PASSWORD = 10
|
|
32
|
+
SCRYPT = {"n": 2 ** 14, "r": 8, "p": 1} # ~16 MiB, ~50 ms per check
|
|
33
|
+
CONFIG_DEFAULTS = {
|
|
34
|
+
"allowed_hosts": [], # extra Host names (any IP literal, localhost and this machine's names always pass)
|
|
35
|
+
"trusted_proxies": [], # proxy IPs whose proxy_user_header is believed (SSO reverse proxy); empty = off
|
|
36
|
+
"proxy_user_header": "X-Forwarded-User",
|
|
37
|
+
"session_hours": 12, # absolute session lifetime
|
|
38
|
+
"idle_minutes": 120, # signed out after this long without a request
|
|
39
|
+
"max_failures": 5, # failed sign-ins per user name within lockout_minutes before it locks
|
|
40
|
+
"lockout_minutes": 15,
|
|
41
|
+
"max_failures_per_ip": 30,
|
|
42
|
+
"secure_cookies": False, # force the Secure cookie flag (behind an HTTPS proxy that does not send X-Forwarded-Proto)
|
|
43
|
+
"request_seconds": 120, # whole request (TLS handshake, headers and body) must arrive within this
|
|
44
|
+
"max_connections_per_ip": 64, # open connections per client address (trusted proxies exempt); 0 = no limit
|
|
45
|
+
"audit_max_mb": 10, # team_audit.jsonl is rotated to .1, .2 ... at this size
|
|
46
|
+
"audit_keep": 5, # rotated audit files kept
|
|
47
|
+
"audit_refusals_per_minute": 20, # unauthenticated refusals logged per address per minute; the rest are counted
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def now():
|
|
52
|
+
return datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0).isoformat()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------- passwords
|
|
56
|
+
def hash_password(password):
|
|
57
|
+
if not isinstance(password, str) or len(password) < MIN_PASSWORD:
|
|
58
|
+
raise ValueError(f"password must be at least {MIN_PASSWORD} characters")
|
|
59
|
+
if len(password) > 1024:
|
|
60
|
+
raise ValueError("password is too long")
|
|
61
|
+
salt = secrets.token_bytes(16)
|
|
62
|
+
dk = hashlib.scrypt(password.encode(), salt=salt, dklen=32, **SCRYPT)
|
|
63
|
+
return {"alg": "scrypt", **SCRYPT, "salt": base64.b64encode(salt).decode(), "hash": base64.b64encode(dk).decode()}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
_DUMMY = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def verify_password(password, rec):
|
|
70
|
+
"""Constant-time check; a missing record still costs one scrypt so unknown names take as long as known ones."""
|
|
71
|
+
global _DUMMY
|
|
72
|
+
if not isinstance(password, str) or len(password) > 1024:
|
|
73
|
+
password = ""
|
|
74
|
+
if not rec or rec.get("alg") != "scrypt":
|
|
75
|
+
_DUMMY = _DUMMY or hash_password("x" * MIN_PASSWORD)
|
|
76
|
+
rec, ok = _DUMMY, False
|
|
77
|
+
else:
|
|
78
|
+
ok = True
|
|
79
|
+
try:
|
|
80
|
+
dk = hashlib.scrypt(password.encode(), salt=base64.b64decode(rec["salt"]), dklen=32,
|
|
81
|
+
n=int(rec["n"]), r=int(rec["r"]), p=int(rec["p"]))
|
|
82
|
+
except (ValueError, KeyError, TypeError):
|
|
83
|
+
return False
|
|
84
|
+
return hmac.compare_digest(dk, base64.b64decode(rec["hash"])) and ok
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------- users file
|
|
88
|
+
@contextlib.contextmanager
|
|
89
|
+
def _file_lock(root):
|
|
90
|
+
"""Serialise writers of team_users.json (the server's admin page and the CLI). No-op where fcntl is missing."""
|
|
91
|
+
try:
|
|
92
|
+
import fcntl
|
|
93
|
+
except ImportError: # Windows: single writer assumed
|
|
94
|
+
yield
|
|
95
|
+
return
|
|
96
|
+
with open(Path(root) / LOCK_FILE, "a") as f:
|
|
97
|
+
fcntl.flock(f, fcntl.LOCK_EX)
|
|
98
|
+
try:
|
|
99
|
+
yield
|
|
100
|
+
finally:
|
|
101
|
+
fcntl.flock(f, fcntl.LOCK_UN)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _write_private(path, text):
|
|
105
|
+
tmp = path.with_name(path.name + f".{os.getpid()}.tmp")
|
|
106
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
107
|
+
with os.fdopen(fd, "w") as f:
|
|
108
|
+
f.write(text)
|
|
109
|
+
os.replace(tmp, path)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def check_username(name):
|
|
113
|
+
name = str(name or "").strip().lower()
|
|
114
|
+
if not USERNAME.match(name):
|
|
115
|
+
raise ValueError("user name: 1-64 characters, letters, digits and . _ @ + - (starting with a letter or digit)")
|
|
116
|
+
return name
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _check_role(role):
|
|
120
|
+
if role not in ROLES:
|
|
121
|
+
raise ValueError(f"role must be one of {', '.join(ROLES)}")
|
|
122
|
+
return role
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _one_line(v, what, n=200):
|
|
126
|
+
v = str(v or "").strip()
|
|
127
|
+
if len(v) > n or any(c in v for c in "\r\n\0<>"):
|
|
128
|
+
raise ValueError(f"{what} must be one line of at most {n} characters without < or >")
|
|
129
|
+
return v
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class UserStore:
|
|
133
|
+
"""team_users.json, re-read whenever it changes on disk (so `ecadlib team ...` applies to a running server)."""
|
|
134
|
+
def __init__(self, root):
|
|
135
|
+
self.root = Path(root)
|
|
136
|
+
self.path = self.root / USERS_FILE
|
|
137
|
+
self._sig, self._users, self._lock = None, {}, threading.Lock()
|
|
138
|
+
|
|
139
|
+
def users(self):
|
|
140
|
+
with self._lock:
|
|
141
|
+
try:
|
|
142
|
+
st = self.path.stat()
|
|
143
|
+
sig = (st.st_mtime_ns, st.st_size)
|
|
144
|
+
except FileNotFoundError:
|
|
145
|
+
self._sig, self._users = None, {}
|
|
146
|
+
return {}
|
|
147
|
+
if sig != self._sig:
|
|
148
|
+
data = json.loads(self.path.read_text() or "{}")
|
|
149
|
+
self._users, self._sig = dict(data.get("users") or {}), sig
|
|
150
|
+
return self._users
|
|
151
|
+
|
|
152
|
+
def get(self, name):
|
|
153
|
+
try:
|
|
154
|
+
return self.users().get(check_username(name))
|
|
155
|
+
except ValueError:
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
def find(self, name_or_email):
|
|
159
|
+
"""A user by user name or e-mail (what an SSO proxy usually sends)."""
|
|
160
|
+
u = self.get(name_or_email)
|
|
161
|
+
if u:
|
|
162
|
+
return u
|
|
163
|
+
key = str(name_or_email or "").strip().lower()
|
|
164
|
+
return next((u for u in self.users().values() if key and (u.get("email") or "").lower() == key), None)
|
|
165
|
+
|
|
166
|
+
def _edit(self, fn):
|
|
167
|
+
with _file_lock(self.root):
|
|
168
|
+
data = json.loads(self.path.read_text() or "{}") if self.path.exists() else {}
|
|
169
|
+
users = data.setdefault("users", {})
|
|
170
|
+
out = fn(users)
|
|
171
|
+
data["version"] = 1
|
|
172
|
+
_write_private(self.path, json.dumps(data, indent=2, sort_keys=True))
|
|
173
|
+
with self._lock:
|
|
174
|
+
self._sig = None
|
|
175
|
+
return out
|
|
176
|
+
|
|
177
|
+
def add(self, name, role, password=None, full_name="", email="", sign_as=None):
|
|
178
|
+
name, role = check_username(name), _check_role(role)
|
|
179
|
+
full_name, email = _one_line(full_name, "name"), _one_line(email, "e-mail")
|
|
180
|
+
if email and not re.match(r"^[^@\s]+@[^@\s]+$", email):
|
|
181
|
+
raise ValueError("e-mail looks wrong")
|
|
182
|
+
pw = hash_password(password) if password is not None else None
|
|
183
|
+
|
|
184
|
+
def fn(users):
|
|
185
|
+
if name in users:
|
|
186
|
+
raise FileExistsError(f"user {name} already exists")
|
|
187
|
+
users[name] = {"username": name, "role": role, "name": full_name, "email": email, "password": pw,
|
|
188
|
+
"sign_as": _sign_as(sign_as), "gen": secrets.randbits(48), "created": now()}
|
|
189
|
+
# gen starts random: a session of a removed user must not come back when the name is added again
|
|
190
|
+
return dict(users[name])
|
|
191
|
+
return self._edit(fn)
|
|
192
|
+
|
|
193
|
+
def set_role(self, name, role, sign_as=None):
|
|
194
|
+
name, role = check_username(name), _check_role(role)
|
|
195
|
+
|
|
196
|
+
def fn(users):
|
|
197
|
+
u = _need(users, name)
|
|
198
|
+
if u["role"] == "admin" and role != "admin" and _admins(users) == 1:
|
|
199
|
+
raise ValueError("that is the last admin: make someone else admin first")
|
|
200
|
+
u["role"] = role
|
|
201
|
+
if sign_as is not None:
|
|
202
|
+
u["sign_as"] = _sign_as(sign_as)
|
|
203
|
+
return dict(u)
|
|
204
|
+
return self._edit(fn)
|
|
205
|
+
|
|
206
|
+
def reset_password(self, name, password):
|
|
207
|
+
name = check_username(name)
|
|
208
|
+
pw = hash_password(password)
|
|
209
|
+
|
|
210
|
+
def fn(users):
|
|
211
|
+
u = _need(users, name)
|
|
212
|
+
u["password"], u["gen"] = pw, int(u.get("gen") or 1) + 1 # signs out every session of this user
|
|
213
|
+
return dict(u)
|
|
214
|
+
return self._edit(fn)
|
|
215
|
+
|
|
216
|
+
def remove(self, name):
|
|
217
|
+
name = check_username(name)
|
|
218
|
+
|
|
219
|
+
def fn(users):
|
|
220
|
+
u = _need(users, name)
|
|
221
|
+
if u["role"] == "admin" and _admins(users) == 1:
|
|
222
|
+
raise ValueError("that is the last admin and cannot be removed")
|
|
223
|
+
del users[name]
|
|
224
|
+
return u
|
|
225
|
+
return self._edit(fn)
|
|
226
|
+
|
|
227
|
+
def public(self):
|
|
228
|
+
return [{k: u.get(k) for k in ("username", "name", "email", "role", "sign_as", "created")} | {"password": bool(u.get("password"))}
|
|
229
|
+
for u in sorted(self.users().values(), key=lambda u: u["username"])]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _sign_as(v):
|
|
233
|
+
if v is None:
|
|
234
|
+
return []
|
|
235
|
+
v = [x.strip() for x in (v.split(",") if isinstance(v, str) else v) if isinstance(x, str) and x.strip()]
|
|
236
|
+
for x in v:
|
|
237
|
+
if not re.match(r"^[A-Za-z0-9 ._-]{1,40}$", x):
|
|
238
|
+
raise ValueError(f"sign-as role {x!r}: letters, digits, space . _ - only")
|
|
239
|
+
return v
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _need(users, name):
|
|
243
|
+
if name not in users:
|
|
244
|
+
raise KeyError(f"no user {name}")
|
|
245
|
+
return users[name]
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _admins(users):
|
|
249
|
+
return sum(u.get("role") == "admin" for u in users.values())
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def identity(user):
|
|
253
|
+
"""How a team user is recorded in approvals: 'Name <email>' (approvals.user_key then keys on the e-mail)."""
|
|
254
|
+
name = user.get("name") or user["username"]
|
|
255
|
+
return f"{name} <{user['email']}>" if user.get("email") else name
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def sign_roles(user):
|
|
259
|
+
return list(user.get("sign_as") or []) or [user["role"]]
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ---------------------------------------------------------------- settings
|
|
263
|
+
def load_config(root, overrides=None):
|
|
264
|
+
f = Path(root) / CONFIG_FILE
|
|
265
|
+
raw = json.loads(f.read_text() or "{}") if f.exists() else {}
|
|
266
|
+
bad = set(raw) - set(CONFIG_DEFAULTS)
|
|
267
|
+
if bad:
|
|
268
|
+
raise ValueError(f"{f}: unknown keys {sorted(bad)}; known: {sorted(CONFIG_DEFAULTS)}")
|
|
269
|
+
cfg = {**CONFIG_DEFAULTS, **raw}
|
|
270
|
+
for k, v in (overrides or {}).items():
|
|
271
|
+
if v not in (None, [], ()):
|
|
272
|
+
cfg[k] = list(cfg[k]) + list(v) if isinstance(CONFIG_DEFAULTS[k], list) else v
|
|
273
|
+
for ip in cfg["trusted_proxies"]:
|
|
274
|
+
ipaddress.ip_address(ip) # raises ValueError on a typo
|
|
275
|
+
cfg["allowed_hosts"] = [h.lower() for h in cfg["allowed_hosts"]]
|
|
276
|
+
return cfg
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# ---------------------------------------------------------------- sessions and sign-in throttling
|
|
280
|
+
class Sessions:
|
|
281
|
+
"""In memory: a restart signs everyone out. token -> {user, gen, csrf, created, seen}."""
|
|
282
|
+
def __init__(self, hours=12, idle_minutes=120):
|
|
283
|
+
self.max_age, self.idle = hours * 3600, idle_minutes * 60
|
|
284
|
+
self.s, self.lock = {}, threading.Lock()
|
|
285
|
+
|
|
286
|
+
def create(self, username, gen):
|
|
287
|
+
tok = secrets.token_urlsafe(32)
|
|
288
|
+
t = time.time()
|
|
289
|
+
with self.lock:
|
|
290
|
+
self.s = {k: v for k, v in self.s.items() if not self._expired(v, t)}
|
|
291
|
+
self.s[tok] = {"user": username, "gen": gen, "csrf": secrets.token_urlsafe(32), "created": t, "seen": t}
|
|
292
|
+
return tok, self.s[tok]
|
|
293
|
+
|
|
294
|
+
def _expired(self, v, t):
|
|
295
|
+
return t - v["created"] > self.max_age or t - v["seen"] > self.idle
|
|
296
|
+
|
|
297
|
+
def get(self, tok):
|
|
298
|
+
if not tok:
|
|
299
|
+
return None
|
|
300
|
+
t = time.time()
|
|
301
|
+
with self.lock:
|
|
302
|
+
v = self.s.get(tok)
|
|
303
|
+
if v and self._expired(v, t):
|
|
304
|
+
del self.s[tok]
|
|
305
|
+
return None
|
|
306
|
+
if v:
|
|
307
|
+
v["seen"] = t
|
|
308
|
+
return v
|
|
309
|
+
|
|
310
|
+
def drop(self, tok):
|
|
311
|
+
with self.lock:
|
|
312
|
+
self.s.pop(tok, None)
|
|
313
|
+
|
|
314
|
+
def remaining(self, v):
|
|
315
|
+
return max(0, int(v["created"] + self.max_age - time.time()))
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class Throttle:
|
|
319
|
+
"""Failed sign-ins per user name and per client address. Unknown names count too (no user enumeration)."""
|
|
320
|
+
def __init__(self, max_failures=5, lockout_minutes=15, max_per_ip=30):
|
|
321
|
+
# at least one try: 0 in team.json used to index an empty list (every sign-in a 500), and is not "off"
|
|
322
|
+
self.max, self.window, self.max_ip = max(1, int(max_failures)), lockout_minutes * 60, max(1, int(max_per_ip))
|
|
323
|
+
self.by_user, self.by_ip, self.lock = {}, {}, threading.Lock()
|
|
324
|
+
|
|
325
|
+
def _recent(self, d, k, t):
|
|
326
|
+
d[k] = [x for x in d.get(k, []) if t - x < self.window]
|
|
327
|
+
return d[k]
|
|
328
|
+
|
|
329
|
+
def _wait(self, user, ip, t):
|
|
330
|
+
u, i = self._recent(self.by_user, user, t), self._recent(self.by_ip, ip, t)
|
|
331
|
+
waits = [int(u[-self.max] + self.window - t) + 1] if len(u) >= self.max else []
|
|
332
|
+
waits += [int(i[-self.max_ip] + self.window - t) + 1] if len(i) >= self.max_ip else []
|
|
333
|
+
return max(waits, default=0)
|
|
334
|
+
|
|
335
|
+
def blocked(self, user, ip):
|
|
336
|
+
"""Seconds until this user name / address may try again, or 0."""
|
|
337
|
+
with self.lock:
|
|
338
|
+
return self._wait(user, ip, time.time())
|
|
339
|
+
|
|
340
|
+
def attempt(self, user, ip):
|
|
341
|
+
"""Check and count one password try in one step: (seconds to wait, None) or (0, ticket). The try counts as a
|
|
342
|
+
failure until ok(user, ip, ticket), so parallel requests cannot all pass the check before any failure is
|
|
343
|
+
recorded (that let N concurrent guesses through instead of max_failures)."""
|
|
344
|
+
t = time.time()
|
|
345
|
+
with self.lock:
|
|
346
|
+
wait = self._wait(user, ip, t)
|
|
347
|
+
if wait:
|
|
348
|
+
return wait, None
|
|
349
|
+
self.by_user[user].append(t)
|
|
350
|
+
self.by_ip[ip].append(t)
|
|
351
|
+
return 0, t
|
|
352
|
+
|
|
353
|
+
def fail(self, user, ip):
|
|
354
|
+
t = time.time()
|
|
355
|
+
with self.lock:
|
|
356
|
+
self._recent(self.by_user, user, t).append(t)
|
|
357
|
+
self._recent(self.by_ip, ip, t).append(t)
|
|
358
|
+
|
|
359
|
+
def ok(self, user, ip=None, ticket=None):
|
|
360
|
+
"""A right password: forget this user's failures (and the try counted by attempt())."""
|
|
361
|
+
with self.lock:
|
|
362
|
+
self.by_user.pop(user, None)
|
|
363
|
+
if ticket is not None:
|
|
364
|
+
with contextlib.suppress(ValueError):
|
|
365
|
+
self.by_ip.get(ip, []).remove(ticket)
|
|
366
|
+
|
|
367
|
+
def unlock(self, user):
|
|
368
|
+
self.ok(user)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class Team:
|
|
372
|
+
"""Everything the web server needs for team mode, for one workspace."""
|
|
373
|
+
def __init__(self, root, overrides=None, tls=False):
|
|
374
|
+
self.root = Path(root)
|
|
375
|
+
self.cfg = load_config(root, overrides)
|
|
376
|
+
self.store = UserStore(root)
|
|
377
|
+
self.sessions = Sessions(self.cfg["session_hours"], self.cfg["idle_minutes"])
|
|
378
|
+
self.throttle = Throttle(self.cfg["max_failures"], self.cfg["lockout_minutes"], self.cfg["max_failures_per_ip"])
|
|
379
|
+
self.secret = secrets.token_bytes(32)
|
|
380
|
+
self.tls = tls
|
|
381
|
+
self.refusals = RefusalLog(self.cfg["audit_refusals_per_minute"])
|
|
382
|
+
|
|
383
|
+
def audit(self, entry):
|
|
384
|
+
"""One team audit line; unauthenticated refusals are rate-limited per address (RefusalLog)."""
|
|
385
|
+
if entry.get("user") is None and entry.get("status", 0) >= 400:
|
|
386
|
+
extra = self.refusals.admit(entry.get("ip"))
|
|
387
|
+
if extra is None:
|
|
388
|
+
return
|
|
389
|
+
entry = {**entry, **extra}
|
|
390
|
+
audit(self.root, entry, int(self.cfg["audit_max_mb"] * 1024 * 1024), int(self.cfg["audit_keep"]))
|
|
391
|
+
|
|
392
|
+
def has_admin(self):
|
|
393
|
+
return any(u.get("role") == "admin" for u in self.store.users().values())
|
|
394
|
+
|
|
395
|
+
def proxy_csrf(self, username):
|
|
396
|
+
return hmac.new(self.secret, b"proxy\0" + username.encode(), hashlib.sha256).hexdigest()
|
|
397
|
+
|
|
398
|
+
def host_ok(self, host_header):
|
|
399
|
+
"""DNS rebinding guard: the Host must be an IP literal, localhost, this machine's name or an allowed name."""
|
|
400
|
+
import socket
|
|
401
|
+
h = str(host_header or "").strip().lower()
|
|
402
|
+
if not h:
|
|
403
|
+
return False
|
|
404
|
+
name = h[1:h.index("]")] if h.startswith("[") and "]" in h else h.rsplit(":", 1)[0] if h.count(":") == 1 else h
|
|
405
|
+
try:
|
|
406
|
+
ipaddress.ip_address(name)
|
|
407
|
+
return True
|
|
408
|
+
except ValueError:
|
|
409
|
+
pass
|
|
410
|
+
names = {"localhost", socket.gethostname().lower(), socket.getfqdn().lower(), *self.cfg["allowed_hosts"]}
|
|
411
|
+
return name in names
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
_audit_lock = threading.Lock()
|
|
415
|
+
AUDIT_MAX_BYTES, AUDIT_KEEP = CONFIG_DEFAULTS["audit_max_mb"] * 1024 * 1024, CONFIG_DEFAULTS["audit_keep"]
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _audit_files(root, keep):
|
|
419
|
+
f = Path(root) / AUDIT_FILE
|
|
420
|
+
return [f] + [f.with_name(f"{AUDIT_FILE}.{i}") for i in range(1, keep + 1)]
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def audit(root, entry, max_bytes=AUDIT_MAX_BYTES, keep=AUDIT_KEEP):
|
|
424
|
+
"""Append one line; at max_bytes the file is rotated (team_audit.jsonl -> .1 -> .2 ..., `keep` old files)."""
|
|
425
|
+
line = json.dumps({"ts": now(), **entry}, sort_keys=True) + "\n"
|
|
426
|
+
with _audit_lock:
|
|
427
|
+
files = _audit_files(root, keep)
|
|
428
|
+
with contextlib.suppress(FileNotFoundError):
|
|
429
|
+
if keep > 0 and files[0].stat().st_size + len(line) > max_bytes:
|
|
430
|
+
for src, dst in reversed(list(zip(files, files[1:]))):
|
|
431
|
+
if src.exists():
|
|
432
|
+
os.replace(src, dst)
|
|
433
|
+
with open(files[0], "a") as f:
|
|
434
|
+
f.write(line)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _tail(path, n, block=65536):
|
|
438
|
+
"""The last n lines of a file, reading backwards from the end (never the whole file)."""
|
|
439
|
+
with open(path, "rb") as f:
|
|
440
|
+
f.seek(0, os.SEEK_END)
|
|
441
|
+
pos, data = f.tell(), b""
|
|
442
|
+
while pos > 0 and data.count(b"\n") <= n:
|
|
443
|
+
step = min(block, pos)
|
|
444
|
+
pos -= step
|
|
445
|
+
f.seek(pos)
|
|
446
|
+
data = f.read(step) + data
|
|
447
|
+
lines = data.splitlines()
|
|
448
|
+
return lines[-n:] if pos == 0 else lines[1:][-n:] # the first line may be cut when we stopped early
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def read_audit(root, n=200, keep=AUDIT_KEEP):
|
|
452
|
+
"""The newest n entries, newest first, from team_audit.jsonl and then its rotated files."""
|
|
453
|
+
out = []
|
|
454
|
+
for f in _audit_files(root, keep):
|
|
455
|
+
if len(out) >= n:
|
|
456
|
+
break
|
|
457
|
+
if not f.exists():
|
|
458
|
+
continue
|
|
459
|
+
for l in reversed(_tail(f, n - len(out))):
|
|
460
|
+
with contextlib.suppress(json.JSONDecodeError, UnicodeDecodeError):
|
|
461
|
+
out.append(json.loads(l))
|
|
462
|
+
return out[:n]
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
class RefusalLog:
|
|
466
|
+
"""Unauthenticated refusals (401/403/429 before sign-in) cost an anonymous client nothing, so each address gets
|
|
467
|
+
`per_minute` audit lines per minute; the rest are counted and reported as "suppressed" on its next line."""
|
|
468
|
+
def __init__(self, per_minute=20):
|
|
469
|
+
self.per_minute, self.seen, self.lock = per_minute, {}, threading.Lock()
|
|
470
|
+
|
|
471
|
+
def admit(self, ip):
|
|
472
|
+
"""None: drop this line. Else a dict to merge into it ({} or {"suppressed": n})."""
|
|
473
|
+
if self.per_minute <= 0:
|
|
474
|
+
return {}
|
|
475
|
+
t = time.time()
|
|
476
|
+
with self.lock:
|
|
477
|
+
if len(self.seen) > 10000: # forget quiet addresses
|
|
478
|
+
self.seen = {k: v for k, v in self.seen.items() if t - v[0] < 60}
|
|
479
|
+
start, count, dropped = self.seen.get(ip, (t, 0, 0))
|
|
480
|
+
if t - start >= 60:
|
|
481
|
+
start, count = t, 0
|
|
482
|
+
if count >= self.per_minute:
|
|
483
|
+
self.seen[ip] = (start, count, dropped + 1)
|
|
484
|
+
return None
|
|
485
|
+
self.seen[ip] = (start, count + 1, 0)
|
|
486
|
+
return {"suppressed": dropped} if dropped else {}
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
# ---------------------------------------------------------------- CLI
|
|
490
|
+
def _read_password(a, prompt="password"):
|
|
491
|
+
if a.password_stdin:
|
|
492
|
+
return sys.stdin.readline().rstrip("\r\n")
|
|
493
|
+
p1 = getpass.getpass(f"{prompt} (at least {MIN_PASSWORD} characters): ")
|
|
494
|
+
if p1 != getpass.getpass("again: "):
|
|
495
|
+
sys.exit("passwords differ")
|
|
496
|
+
return p1
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def main(argv=None):
|
|
500
|
+
argv = sys.argv[1:] if argv is None else list(argv)
|
|
501
|
+
if argv[:1] == ["setup"]: # Docker Compose + Caddy for the whole team (team_setup.py)
|
|
502
|
+
from . import team_setup
|
|
503
|
+
return team_setup.main(argv[1:])
|
|
504
|
+
ap = argparse.ArgumentParser(prog="ecadlib team", description="manage web UI team users (ecadlib ui --team)",
|
|
505
|
+
epilog="ecadlib team setup -h: run the team server with Docker and HTTPS in one command")
|
|
506
|
+
ap.add_argument("--workspace", default="ecadlib_workspace")
|
|
507
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
508
|
+
p = sub.add_parser("add-user", help="add a user (the first one must be an admin)")
|
|
509
|
+
p.add_argument("user")
|
|
510
|
+
p.add_argument("--role", required=True, choices=ROLES)
|
|
511
|
+
p.add_argument("--name", default="", help="full name, recorded with approvals")
|
|
512
|
+
p.add_argument("--email", default="", help="e-mail, recorded with approvals and matched by approvals.json users")
|
|
513
|
+
p.add_argument("--sign-as", help="comma list of approval roles this user signs as (default: the team role)")
|
|
514
|
+
p.add_argument("--password-stdin", action="store_true", help="read the password from one line of stdin")
|
|
515
|
+
p.add_argument("--no-password", action="store_true", help="sign-in only through the trusted SSO proxy header")
|
|
516
|
+
p = sub.add_parser("set-role", help="change a user's role")
|
|
517
|
+
p.add_argument("user")
|
|
518
|
+
p.add_argument("role", choices=ROLES)
|
|
519
|
+
p.add_argument("--sign-as", help="comma list of approval roles (empty string: the team role)")
|
|
520
|
+
p = sub.add_parser("reset-password", help="set a new password; signs the user out everywhere")
|
|
521
|
+
p.add_argument("user")
|
|
522
|
+
p.add_argument("--password-stdin", action="store_true")
|
|
523
|
+
p = sub.add_parser("remove", help="remove a user")
|
|
524
|
+
p.add_argument("user")
|
|
525
|
+
sub.add_parser("list", help="list users")
|
|
526
|
+
a = ap.parse_args(argv)
|
|
527
|
+
root = Path(a.workspace).resolve()
|
|
528
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
529
|
+
st = UserStore(root)
|
|
530
|
+
try:
|
|
531
|
+
if a.cmd == "add-user":
|
|
532
|
+
if not st.users() and a.role != "admin":
|
|
533
|
+
sys.exit("the first user must be an admin (--role admin)")
|
|
534
|
+
pw = None if a.no_password else _read_password(a)
|
|
535
|
+
u = st.add(a.user, a.role, pw, a.name, a.email, a.sign_as)
|
|
536
|
+
print(f"added {u['username']} as {u['role']} -> {st.path}")
|
|
537
|
+
elif a.cmd == "set-role":
|
|
538
|
+
u = st.set_role(a.user, a.role, a.sign_as)
|
|
539
|
+
print(f"{u['username']} is now {u['role']}" + (f", signs as {', '.join(u['sign_as'])}" if u.get("sign_as") else ""))
|
|
540
|
+
elif a.cmd == "reset-password":
|
|
541
|
+
st.reset_password(a.user, _read_password(a, "new password"))
|
|
542
|
+
print(f"password reset for {check_username(a.user)}; their sessions end on their next request")
|
|
543
|
+
elif a.cmd == "remove":
|
|
544
|
+
st.remove(a.user)
|
|
545
|
+
print(f"removed {check_username(a.user)}")
|
|
546
|
+
else:
|
|
547
|
+
for u in st.public():
|
|
548
|
+
print(f" {u['username']:<20} {u['role']:<10} {identity(u) if u.get('name') or u.get('email') else '':<36}"
|
|
549
|
+
+ ("" if u["password"] else " (SSO proxy only)"))
|
|
550
|
+
if not st.users():
|
|
551
|
+
print(f"no users yet: ecadlib team --workspace {a.workspace} add-user NAME --role admin")
|
|
552
|
+
except (ValueError, KeyError, FileExistsError) as e:
|
|
553
|
+
sys.exit(f"ecadlib team: {e.args[0] if e.args else e}")
|
|
554
|
+
return 0
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
if __name__ == "__main__":
|
|
558
|
+
sys.exit(main())
|