create-workframe 0.1.11 → 0.1.13
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.
- package/README.md +1 -1
- package/bin/create-workframe.js +2 -2
- package/bin/workframe.js +130 -2
- package/docs/security.md +85 -0
- package/package.json +1 -1
- package/scripts/bundle-workframe-ui.mjs +9 -0
- package/scripts/sync-canonical-to-package.mjs +9 -0
- package/scripts/verify-public-deploy.sh +121 -3
- package/workframe-api/action_proxy.py +23 -17
- package/workframe-api/activity_feed.py +798 -0
- package/workframe-api/api_meta.py +157 -0
- package/workframe-api/auth_gate.py +566 -0
- package/workframe-api/avatar_registry.py +343 -0
- package/workframe-api/broker_audit.py +164 -0
- package/workframe-api/cell_authority.py +231 -0
- package/workframe-api/chat_bind.py +319 -0
- package/workframe-api/chat_sessions.py +509 -0
- package/workframe-api/chat_stream.py +694 -0
- package/workframe-api/cleanup_dogfood_smoke.py +252 -0
- package/workframe-api/credential_broker.py +162 -0
- package/workframe-api/credential_resolve.py +202 -0
- package/workframe-api/credential_store.py +245 -0
- package/workframe-api/crew_registry.py +250 -0
- package/workframe-api/db_schema.py +755 -0
- package/workframe-api/docker_gateway.py +166 -0
- package/workframe-api/doctor_runtime.py +109 -0
- package/workframe-api/domain/__init__.py +47 -0
- package/workframe-api/domain/entities.py +252 -0
- package/workframe-api/domain/schema/workframe-domain.schema.json +278 -0
- package/workframe-api/egress_policy.py +42 -0
- package/workframe-api/handler_modules/__init__.py +17 -0
- package/workframe-api/handler_modules/handler_admin.py +542 -0
- package/workframe-api/handler_modules/handler_auth.py +512 -0
- package/workframe-api/handler_modules/handler_chat.py +641 -0
- package/workframe-api/handler_modules/handler_install.py +178 -0
- package/workframe-api/handler_modules/handler_provider.py +325 -0
- package/workframe-api/handler_modules/handler_workspace.py +1420 -0
- package/workframe-api/health_monitor.py +80 -0
- package/workframe-api/hermes_admin.py +756 -0
- package/workframe-api/hermes_profiles.py +1328 -0
- package/workframe-api/install_api.py +123 -0
- package/workframe-api/kanban_cron.py +473 -0
- package/workframe-api/lane_bindings.py +423 -0
- package/workframe-api/llm_proxy.py +17 -43
- package/workframe-api/mention_helpers.py +180 -0
- package/workframe-api/mention_invoke.py +431 -0
- package/workframe-api/model_surface.py +1139 -0
- package/workframe-api/oauth_pending.py +85 -0
- package/workframe-api/oauth_redirect.py +381 -0
- package/workframe-api/openrouter_catalog.py +4 -4
- package/workframe-api/package.json +2 -2
- package/workframe-api/profile_api_lifecycle.py +66 -0
- package/workframe-api/profile_config_yaml.py +126 -0
- package/workframe-api/profile_gateway.py +436 -0
- package/workframe-api/provider_bindings.py +673 -0
- package/workframe-api/provider_bootstrap.py +347 -0
- package/workframe-api/provider_catalog.py +180 -0
- package/workframe-api/rooms.py +1613 -0
- package/workframe-api/route_registry.py +470 -0
- package/workframe-api/run-typecheck.mjs +26 -0
- package/workframe-api/run_authority.py +287 -0
- package/workframe-api/run_ledger.py +470 -0
- package/workframe-api/run_surface_wiring.py +344 -0
- package/workframe-api/runtime_cohort.py +752 -0
- package/workframe-api/runtime_tokens.py +127 -0
- package/workframe-api/server.py +1376 -19305
- package/workframe-api/snapshot_feed.py +49 -0
- package/workframe-api/stack_config.py +33 -1
- package/workframe-api/supervisor_client.py +154 -0
- package/workframe-api/test_api_meta_build_stamp.py +34 -0
- package/workframe-api/test_cell_authority.py +79 -0
- package/workframe-api/test_chat_bind.py +48 -0
- package/workframe-api/test_credential_lease_matrix.py +171 -0
- package/workframe-api/test_credential_resolve.py +51 -0
- package/workframe-api/test_credential_store.py +27 -0
- package/workframe-api/test_dogfood_flows.py +346 -0
- package/workframe-api/test_domain_entities.py +152 -0
- package/workframe-api/test_exception_hygiene.py +12 -0
- package/workframe-api/test_hermes_admin.py +72 -0
- package/workframe-api/test_install_wizard_resume.py +78 -0
- package/workframe-api/test_local_bootstrap.py +67 -0
- package/workframe-api/test_mention_helpers.py +35 -0
- package/workframe-api/test_mention_invoke.py +26 -0
- package/workframe-api/test_model_surface_consistency.py +117 -0
- package/workframe-api/test_oauth_pending.py +41 -0
- package/workframe-api/test_profile_model_yaml.py +99 -0
- package/workframe-api/test_provider_bindings.py +52 -0
- package/workframe-api/test_provider_catalog.py +28 -0
- package/workframe-api/test_public_routes.py +41 -0
- package/workframe-api/test_route_registry.py +245 -0
- package/workframe-api/test_run_authority.py +112 -0
- package/workframe-api/test_run_ledger.py +157 -0
- package/workframe-api/test_run_surface_wiring.py +130 -0
- package/workframe-api/test_runtime_cohort.py +85 -0
- package/workframe-api/test_secure_mode_docker_boundary.py +39 -0
- package/workframe-api/test_server_reexports.py +99 -0
- package/workframe-api/test_stack_config_install_smtp.py +7 -0
- package/workframe-api/time-bind-steps.py +75 -0
- package/workframe-api/time-perf.py +81 -0
- package/workframe-api/time-warm.py +107 -0
- package/workframe-api/turn_credentials.py +28 -13
- package/workframe-api/turn_overlay.py +715 -0
- package/workframe-api/updates.py +16 -1
- package/workframe-api/user_prefs.py +387 -0
- package/workframe-api/wf032_extract_remaining_handlers.py +417 -0
- package/workframe-api/workspace_bootstrap.py +536 -0
- package/workframe-api/workspace_files.py +344 -0
- package/workframe-api/workspace_messaging.py +206 -0
- package/workframe-api/zk_auth.py +3 -2
- package/workframe-supervisor/server.py +10 -1
- package/workframe-supervisor/test_supervisor_negative.py +75 -0
- package/workframe-ui/public/assets/{arc-DjpS9a2b.js → arc-aUUffclm.js} +1 -1
- package/workframe-ui/public/assets/architecture-7EHR7CIX-BSUP4S8J.js +1 -0
- package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-puCKmKHV.js → architectureDiagram-3BPJPVTR-DU6oaqIb.js} +1 -1
- package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-C2P0g-eH.js → blockDiagram-GPEHLZMM-D2p8BU6-.js} +1 -1
- package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-DdvYEITH.js → c4Diagram-AAUBKEIU-ouwyPmTJ.js} +1 -1
- package/workframe-ui/public/assets/channel-DOunZZ0X.js +1 -0
- package/workframe-ui/public/assets/{chunk-2J33WTMH-Cxe8QsEF.js → chunk-2J33WTMH-DsE7U5dj.js} +1 -1
- package/workframe-ui/public/assets/{chunk-3OPIFGDE-DPNyTwR3.js → chunk-3OPIFGDE-DM0kkZ9h.js} +1 -1
- package/workframe-ui/public/assets/{chunk-4BX2VUAB-XKG9gfRN.js → chunk-4BX2VUAB-CQ8mAyXp.js} +1 -1
- package/workframe-ui/public/assets/{chunk-55IACEB6-oPMTulgb.js → chunk-55IACEB6-DN1BDtbH.js} +1 -1
- package/workframe-ui/public/assets/{chunk-5ZQYHXKU-BWwXvpa_.js → chunk-5ZQYHXKU-BzK2KqOt.js} +1 -1
- package/workframe-ui/public/assets/{chunk-727SXJPM-Cml2twO_.js → chunk-727SXJPM-C3AxtOi9.js} +1 -1
- package/workframe-ui/public/assets/{chunk-AQP2D5EJ-DzrRBhQu.js → chunk-AQP2D5EJ-4mVwEFuD.js} +1 -1
- package/workframe-ui/public/assets/{chunk-BSJP7CBP-EZ7STU6h.js → chunk-BSJP7CBP-23kN2q0A.js} +1 -1
- package/workframe-ui/public/assets/{chunk-CSCIHK7Q-BGTQzWYw.js → chunk-CSCIHK7Q-CPBKVW-L.js} +2 -2
- package/workframe-ui/public/assets/{chunk-FMBD7UC4-Cm4stfRa.js → chunk-FMBD7UC4-CKqcYq7K.js} +1 -1
- package/workframe-ui/public/assets/{chunk-KSCS5N6A-exbMijjy.js → chunk-KSCS5N6A-JRQnhxQE.js} +1 -1
- package/workframe-ui/public/assets/{chunk-L5ZTLDWV-C75kbQwN.js → chunk-L5ZTLDWV-BLZrdIwa.js} +1 -1
- package/workframe-ui/public/assets/chunk-LZXEDZCA-DisG0XJT.js +2 -0
- package/workframe-ui/public/assets/{chunk-ND2GUHAM-D84zyIu2.js → chunk-ND2GUHAM-ClAOxArS.js} +1 -1
- package/workframe-ui/public/assets/{chunk-NZK2D7GU-CUNNo1bj.js → chunk-NZK2D7GU-XUE1aNkm.js} +1 -1
- package/workframe-ui/public/assets/{chunk-O5CBEL6O-DN6WEIh-.js → chunk-O5CBEL6O-DDN-Ifon.js} +1 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-EVQ0VMd5.js +1 -0
- package/workframe-ui/public/assets/chunk-WU5MYG2G-k_-ZsLOS.js +1 -0
- package/workframe-ui/public/assets/{chunk-XPW4576I-Ct5VQv2D.js → chunk-XPW4576I-B4_iYZ6r.js} +1 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-W9WVQsby.js +1 -0
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-W9WVQsby.js +1 -0
- package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-Bj4W5oMk.js → cose-bilkent-S5V4N54A-Cy1HhS7j.js} +1 -1
- package/workframe-ui/public/assets/{dagre-BM42HDAG-DAjHBkAy.js → dagre-BM42HDAG-Bjal81Ie.js} +1 -1
- package/workframe-ui/public/assets/{diagram-2AECGRRQ-Ch2gGFPG.js → diagram-2AECGRRQ-v5Gdvzp_.js} +1 -1
- package/workframe-ui/public/assets/{diagram-5GNKFQAL-BzIIvgYH.js → diagram-5GNKFQAL-DuyRdfmY.js} +1 -1
- package/workframe-ui/public/assets/{diagram-KO2AKTUF-Df0Q8nv6.js → diagram-KO2AKTUF-WwWCGbIW.js} +1 -1
- package/workframe-ui/public/assets/{diagram-LMA3HP47-D-G3ekmF.js → diagram-LMA3HP47-DTYwZALT.js} +1 -1
- package/workframe-ui/public/assets/{diagram-OG6HWLK6-TBHhT9f5.js → diagram-OG6HWLK6-B46kBIqE.js} +1 -1
- package/workframe-ui/public/assets/{dist-CPbiDkEZ.js → dist-mQWmB3z6.js} +1 -1
- package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-YxkUmLLV.js → erDiagram-TEJ5UH35-__40xO-y.js} +1 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-Cm1uRTxU.js +1 -0
- package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-kfZ7-RSd.js → flowDiagram-I6XJVG4X-Cwj6dVUz.js} +1 -1
- package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-DmTx3hzR.js → ganttDiagram-6RSMTGT7-D_IOSwAS.js} +1 -1
- package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BXa0ewp3.js → gitGraph-WXDBUCRP-C1vGecee.js} +1 -1
- package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-CQr2qMxU.js → gitGraphDiagram-PVQCEYII-DMTxFJxn.js} +1 -1
- package/workframe-ui/public/assets/index-BdiM4-lI.js +130 -0
- package/workframe-ui/public/assets/index-Fnad47vV.css +1 -0
- package/workframe-ui/public/assets/{info-J43DQDTF-t-Qlx8zv.js → info-J43DQDTF-CwjmyPmD.js} +1 -1
- package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-CzkYofPx.js → infoDiagram-5YYISTIA-BGzh7ZL2.js} +1 -1
- package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-djK3Oxgb.js → ishikawaDiagram-YF4QCWOH-BLXdnR_5.js} +1 -1
- package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-CWxYXxdF.js → journeyDiagram-JHISSGLW-BK3behMe.js} +1 -1
- package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-D7leCAD9.js → kanban-definition-UN3LZRKU-Bx172cCl.js} +1 -1
- package/workframe-ui/public/assets/{line-BSFj2OC5.js → line-BTCEHb7l.js} +1 -1
- package/workframe-ui/public/assets/{linear-pfPwnu-o.js → linear-CkbydpnK.js} +1 -1
- package/workframe-ui/public/assets/{mermaid-parser.core-Dm2L9Ib_.js → mermaid-parser.core-DvrtArpk.js} +2 -2
- package/workframe-ui/public/assets/{mermaid.core-Bevx9bif.js → mermaid.core-yj_1x_qj.js} +3 -3
- package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-UkjDssmP.js → mindmap-definition-RKZ34NQL-BXbMltQ0.js} +1 -1
- package/workframe-ui/public/assets/{packet-YPE3B663-JO2ezH3_.js → packet-YPE3B663-9pVCBQ7S.js} +1 -1
- package/workframe-ui/public/assets/{pie-LRSECV5Y-DdMFsfR8.js → pie-LRSECV5Y-CdqoWesP.js} +1 -1
- package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-CEPHFBU1.js → pieDiagram-4H26LBE5-kGs-VfXd.js} +1 -1
- package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-_RF9_86k.js → quadrantDiagram-W4KKPZXB-D9D8gQw5.js} +1 -1
- package/workframe-ui/public/assets/{radar-GUYGQ44K-Cb-YfdaA.js → radar-GUYGQ44K-d4zMZpQS.js} +1 -1
- package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-DBVOxmBn.js → requirementDiagram-4Y6WPE33-BI7EQ9zc.js} +1 -1
- package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-BTpxsGUA.js → sankeyDiagram-5OEKKPKP-Bcz19jQB.js} +1 -1
- package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-DHP_Bq3l.js → sequenceDiagram-3UESZ5HK-iQiCEfYp.js} +1 -1
- package/workframe-ui/public/assets/{src-CY_By-r7.js → src-D6mvlP96.js} +1 -1
- package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BVtp_LPR.js → stateDiagram-AJRCARHV-CxaTXs02.js} +1 -1
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CXikj9xi.js +1 -0
- package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-D38HJhop.js → timeline-definition-PNZ67QCA-Bej-EAnf.js} +1 -1
- package/workframe-ui/public/assets/{treeView-BLDUP644-CFCG3Sc_.js → treeView-BLDUP644-C7PnnzwX.js} +1 -1
- package/workframe-ui/public/assets/{treemap-LRROVOQU-BeiMs_dF.js → treemap-LRROVOQU-CqISLmkO.js} +1 -1
- package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CKMeT6PW.js → vennDiagram-CIIHVFJN-eJMTTEW-.js} +1 -1
- package/workframe-ui/public/assets/{wardley-L42UT6IY-BljDy8ph.js → wardley-L42UT6IY-D4sMroBF.js} +1 -1
- package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DEE8D94J.js → wardleyDiagram-YWT4CUSO-DC3DCUs7.js} +1 -1
- package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-BRaAWV_g.js → xychartDiagram-2RQKCTM6-BeMaM6Aq.js} +1 -1
- package/workframe-ui/public/index.html +7 -6
- package/workframe-ui/public/workframe-build.json +5 -0
- package/workframe-ui/public/assets/architecture-7EHR7CIX-CvhbXEvt.js +0 -1
- package/workframe-ui/public/assets/channel-D63s_yN9.js +0 -1
- package/workframe-ui/public/assets/chunk-LZXEDZCA-CsRdBqUB.js +0 -2
- package/workframe-ui/public/assets/chunk-QZHKN3VN-Dj_Yxhgo.js +0 -1
- package/workframe-ui/public/assets/chunk-WU5MYG2G-AFchshb6.js +0 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-Opj8t01I.js +0 -1
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-Opj8t01I.js +0 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-LQOYL50n.js +0 -1
- package/workframe-ui/public/assets/index-B08aShJy.css +0 -1
- package/workframe-ui/public/assets/index-DCF3W3G_.js +0 -129
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CvJhS_BJ.js +0 -1
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
"""Auth gate: session, CORS/host validation, deployment mode (WF-032)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import sqlite3
|
|
10
|
+
import time
|
|
11
|
+
import urllib.parse
|
|
12
|
+
import urllib.request
|
|
13
|
+
from http.server import BaseHTTPRequestHandler
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from email_sender import APP_BASE_URL
|
|
18
|
+
import internal_proxy_auth
|
|
19
|
+
import route_registry
|
|
20
|
+
import stack_config
|
|
21
|
+
import zk_auth as _zk
|
|
22
|
+
|
|
23
|
+
DASHBOARD_HTTP_TIMEOUT = float(os.environ.get("HERMES_DASHBOARD_HTTP_TIMEOUT", "15"))
|
|
24
|
+
WORKFRAME_AUTH_INSECURE = os.environ.get("WORKFRAME_AUTH_INSECURE", "0").strip() in {"1", "true", "yes"}
|
|
25
|
+
DOCKER_SOCK = os.environ.get("DOCKER_SOCK", "/var/run/docker.sock")
|
|
26
|
+
|
|
27
|
+
OWNER_ADMIN_ROLES = {"owner", "admin"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _srv():
|
|
31
|
+
import server as srv # ponytail: break circular import at module load
|
|
32
|
+
|
|
33
|
+
return srv
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _is_truthy(value: str | None) -> bool:
|
|
37
|
+
return (value or "").strip().lower() in {"1", "true", "yes", "on"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _dashboard_session_token(internal_url: str) -> str:
|
|
41
|
+
import http.cookiejar
|
|
42
|
+
|
|
43
|
+
jar = http.cookiejar.CookieJar()
|
|
44
|
+
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
|
45
|
+
|
|
46
|
+
def _fetch_html(url: str) -> str:
|
|
47
|
+
with opener.open(url, timeout=DASHBOARD_HTTP_TIMEOUT) as resp:
|
|
48
|
+
return resp.read().decode("utf-8", errors="replace")
|
|
49
|
+
|
|
50
|
+
def _token_from_html(html: str) -> str:
|
|
51
|
+
token_match = re.search(r"__HERMES_SESSION_TOKEN__=\"([^\"]+)\"", html)
|
|
52
|
+
return token_match.group(1) if token_match else ""
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
html = _fetch_html(f"{internal_url}/")
|
|
56
|
+
except Exception as exc: # noqa: BLE001
|
|
57
|
+
raise ValueError(f"dashboard unavailable: {exc}") from exc
|
|
58
|
+
|
|
59
|
+
token = _token_from_html(html)
|
|
60
|
+
if token:
|
|
61
|
+
return token
|
|
62
|
+
|
|
63
|
+
user = os.environ.get("HERMES_DASHBOARD_BASIC_AUTH_USERNAME", "").strip()
|
|
64
|
+
password = os.environ.get("HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", "").strip()
|
|
65
|
+
if user and password:
|
|
66
|
+
body = json.dumps({"username": user, "password": password}).encode("utf-8")
|
|
67
|
+
req = urllib.request.Request(
|
|
68
|
+
f"{internal_url}/auth/password-login",
|
|
69
|
+
data=body,
|
|
70
|
+
headers={"Content-Type": "application/json"},
|
|
71
|
+
method="POST",
|
|
72
|
+
)
|
|
73
|
+
try:
|
|
74
|
+
opener.open(req, timeout=DASHBOARD_HTTP_TIMEOUT).read()
|
|
75
|
+
html = _fetch_html(f"{internal_url}/")
|
|
76
|
+
token = _token_from_html(html)
|
|
77
|
+
if token:
|
|
78
|
+
return token
|
|
79
|
+
except Exception as exc: # noqa: BLE001
|
|
80
|
+
raise ValueError(f"dashboard login failed: {exc}") from exc
|
|
81
|
+
|
|
82
|
+
raise ValueError("dashboard session token not found")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _resolve_mode_flags() -> tuple[bool, bool]:
|
|
86
|
+
secure = _is_truthy(os.environ.get("SECURE_MODE"))
|
|
87
|
+
dev_unsafe = _is_truthy(os.environ.get("DEV_LOCAL_UNSAFE"))
|
|
88
|
+
if secure and dev_unsafe:
|
|
89
|
+
raise SystemExit(
|
|
90
|
+
"SECURE_MODE and DEV_LOCAL_UNSAFE are mutually exclusive. "
|
|
91
|
+
"Set one or neither (neither defaults to secure)."
|
|
92
|
+
)
|
|
93
|
+
if not secure and not dev_unsafe:
|
|
94
|
+
secure = True
|
|
95
|
+
return secure, dev_unsafe
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _require_auth_token() -> str:
|
|
99
|
+
token = os.environ.get("WORKFRAME_API_TOKEN", "").strip()
|
|
100
|
+
if token:
|
|
101
|
+
return token
|
|
102
|
+
dashboard_url = os.environ.get("HERMES_DASHBOARD_URL", "http://127.0.0.1:9119")
|
|
103
|
+
return _dashboard_session_token(dashboard_url)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
SECURE_MODE, DEV_LOCAL_UNSAFE = _resolve_mode_flags()
|
|
107
|
+
DEPLOYMENT_MODES = frozenset({"single_user_local", "trusted_team", "public_multi_user"})
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _resolve_deployment_mode() -> str:
|
|
111
|
+
mode = stack_config.resolve_deployment_mode("trusted_team")
|
|
112
|
+
if mode not in DEPLOYMENT_MODES:
|
|
113
|
+
raise SystemExit(
|
|
114
|
+
f"Invalid WORKFRAME_DEPLOYMENT_MODE={mode!r}. "
|
|
115
|
+
f"Use one of: {', '.join(sorted(DEPLOYMENT_MODES))}"
|
|
116
|
+
)
|
|
117
|
+
return mode
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
DEPLOYMENT_MODE = _resolve_deployment_mode()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _invite_only_login_enforced() -> bool:
|
|
124
|
+
if DEPLOYMENT_MODE == "single_user_local" or DEV_LOCAL_UNSAFE:
|
|
125
|
+
return False
|
|
126
|
+
return not _srv()._install_window_open()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _primary_workspace_row(conn: sqlite3.Connection) -> sqlite3.Row | None:
|
|
130
|
+
return conn.execute(
|
|
131
|
+
"""
|
|
132
|
+
SELECT id, display_name FROM workspaces
|
|
133
|
+
WHERE deleted_at IS NULL
|
|
134
|
+
ORDER BY CASE WHEN slug = 'default' THEN 0 ELSE 1 END, created_at ASC
|
|
135
|
+
LIMIT 1
|
|
136
|
+
""",
|
|
137
|
+
).fetchone()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _pending_invite_for_email(conn: sqlite3.Connection, workspace_id: str, email: str) -> sqlite3.Row | None:
|
|
141
|
+
now_ts = int(time.time())
|
|
142
|
+
return conn.execute(
|
|
143
|
+
"""
|
|
144
|
+
SELECT id FROM workspace_invites
|
|
145
|
+
WHERE workspace_id = ? AND LOWER(email) = ? AND deleted_at IS NULL
|
|
146
|
+
AND accepted_at IS NULL AND CAST(expires_at AS INTEGER) > ?
|
|
147
|
+
LIMIT 1
|
|
148
|
+
""",
|
|
149
|
+
(workspace_id, str(email or "").strip().lower(), now_ts),
|
|
150
|
+
).fetchone()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def email_allowed_to_authenticate(email: str) -> tuple[bool, dict[str, Any]]:
|
|
154
|
+
normalized = str(email or "").strip().lower()
|
|
155
|
+
if not normalized or "@" not in normalized:
|
|
156
|
+
return False, {"error": "email required"}
|
|
157
|
+
if not _invite_only_login_enforced():
|
|
158
|
+
return True, {}
|
|
159
|
+
conn = _srv()._workframe_db()
|
|
160
|
+
try:
|
|
161
|
+
ws = _primary_workspace_row(conn)
|
|
162
|
+
if not ws:
|
|
163
|
+
return True, {}
|
|
164
|
+
ws_id = str(ws["id"])
|
|
165
|
+
user_count = int(conn.execute("SELECT COUNT(*) FROM users WHERE deleted_at IS NULL").fetchone()[0] or 0)
|
|
166
|
+
if user_count == 0:
|
|
167
|
+
return True, {}
|
|
168
|
+
member = conn.execute(
|
|
169
|
+
"""
|
|
170
|
+
SELECT 1 FROM workspace_memberships wm
|
|
171
|
+
JOIN users u ON u.id = wm.user_id
|
|
172
|
+
WHERE wm.workspace_id = ? AND wm.deleted_at IS NULL AND wm.status = 'active'
|
|
173
|
+
AND LOWER(u.email) = ?
|
|
174
|
+
LIMIT 1
|
|
175
|
+
""",
|
|
176
|
+
(ws_id, normalized),
|
|
177
|
+
).fetchone()
|
|
178
|
+
if member:
|
|
179
|
+
return True, {}
|
|
180
|
+
if _pending_invite_for_email(conn, ws_id, normalized):
|
|
181
|
+
return True, {}
|
|
182
|
+
display = str(ws["display_name"] or "this workspace")
|
|
183
|
+
return False, {
|
|
184
|
+
"error": "private_workspace",
|
|
185
|
+
"workspace_name": display,
|
|
186
|
+
"message": (
|
|
187
|
+
f'This Workframe is private. Contact the Admin of "{display}" to get an invite link.'
|
|
188
|
+
),
|
|
189
|
+
}
|
|
190
|
+
finally:
|
|
191
|
+
conn.close()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def invite_token_allows_email(invite_token: str, email: str) -> bool:
|
|
195
|
+
token = str(invite_token or "").strip()
|
|
196
|
+
if not token:
|
|
197
|
+
return False
|
|
198
|
+
token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest()
|
|
199
|
+
normalized = str(email or "").strip().lower()
|
|
200
|
+
conn = _srv()._workframe_db()
|
|
201
|
+
try:
|
|
202
|
+
inv = conn.execute(
|
|
203
|
+
"""
|
|
204
|
+
SELECT email, expires_at FROM workspace_invites
|
|
205
|
+
WHERE token_hash = ? AND deleted_at IS NULL AND accepted_at IS NULL
|
|
206
|
+
LIMIT 1
|
|
207
|
+
""",
|
|
208
|
+
(token_hash,),
|
|
209
|
+
).fetchone()
|
|
210
|
+
if not inv or str(inv["email"]).strip().lower() != normalized:
|
|
211
|
+
return False
|
|
212
|
+
return int(inv["expires_at"]) > int(time.time())
|
|
213
|
+
finally:
|
|
214
|
+
conn.close()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def deployment_security_errors() -> list[str]:
|
|
218
|
+
errors: list[str] = []
|
|
219
|
+
if DEPLOYMENT_MODE != "public_multi_user":
|
|
220
|
+
return errors
|
|
221
|
+
if _srv()._install_window_open():
|
|
222
|
+
return errors
|
|
223
|
+
if DEV_LOCAL_UNSAFE:
|
|
224
|
+
errors.append("public_multi_user cannot run with DEV_LOCAL_UNSAFE")
|
|
225
|
+
if not SECURE_MODE:
|
|
226
|
+
errors.append("public_multi_user requires SECURE_MODE=true")
|
|
227
|
+
if not _srv()._supervisor_ready():
|
|
228
|
+
errors.append("public_multi_user requires WORKFRAME_SUPERVISOR_URL and WORKFRAME_SUPERVISOR_TOKEN")
|
|
229
|
+
for key in ("ZK_AUTH_HMAC_KEY", "ZK_AUTH_ENCRYPTION_KEY", "ZK_AUTH_SESSION_SECRET"):
|
|
230
|
+
if not os.environ.get(key, "").strip():
|
|
231
|
+
errors.append(f"public_multi_user requires {key}")
|
|
232
|
+
if not os.environ.get("WORKFRAME_API_TOKEN", "").strip():
|
|
233
|
+
errors.append("public_multi_user requires WORKFRAME_API_TOKEN")
|
|
234
|
+
if not stack_config.smtp_configured():
|
|
235
|
+
errors.append("public_multi_user requires SMTP_HOST")
|
|
236
|
+
base = str(stack_config.get_stack_config().get("app_base_url") or APP_BASE_URL).strip()
|
|
237
|
+
if not base.lower().startswith("https://"):
|
|
238
|
+
errors.append("public_multi_user requires HTTPS APP_BASE_URL")
|
|
239
|
+
if not os.environ.get("WORKFRAME_VAULT_KEK", "").strip():
|
|
240
|
+
errors.append("public_multi_user requires WORKFRAME_VAULT_KEK")
|
|
241
|
+
data_dir = _srv().DATA_DIR
|
|
242
|
+
if not os.environ.get("WORKFRAME_PROXY_TOKEN", "").strip() and not (
|
|
243
|
+
data_dir / internal_proxy_auth.PROXY_TOKEN_FILE
|
|
244
|
+
).is_file():
|
|
245
|
+
errors.append("public_multi_user requires WORKFRAME_PROXY_TOKEN")
|
|
246
|
+
if Path(DOCKER_SOCK).exists():
|
|
247
|
+
errors.append("public_multi_user must not mount docker.sock on workframe-api")
|
|
248
|
+
return errors
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def deployment_security_warnings() -> list[str]:
|
|
252
|
+
warnings: list[str] = []
|
|
253
|
+
if DEPLOYMENT_MODE == "trusted_team" and DEV_LOCAL_UNSAFE:
|
|
254
|
+
warnings.append("trusted_team + DEV_LOCAL_UNSAFE: session and dashboard gates are relaxed for local dev")
|
|
255
|
+
return warnings
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
CORS_ALLOW_ORIGINS = tuple(
|
|
259
|
+
origin.strip()
|
|
260
|
+
for origin in os.environ.get("CORS_ALLOW_ORIGIN", "").split(",")
|
|
261
|
+
if origin.strip() and origin.strip() != "*"
|
|
262
|
+
)
|
|
263
|
+
ALLOWED_HOSTS = tuple(
|
|
264
|
+
host.strip().lower()
|
|
265
|
+
for host in os.environ.get("ALLOWED_HOSTS", "").split(",")
|
|
266
|
+
if host.strip()
|
|
267
|
+
)
|
|
268
|
+
DEFAULT_ALLOWED_HOSTS = {"127.0.0.1", "localhost", "::1", "0.0.0.0"}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def session_cookie_secure() -> bool:
|
|
272
|
+
if DEV_LOCAL_UNSAFE or WORKFRAME_AUTH_INSECURE:
|
|
273
|
+
return False
|
|
274
|
+
if os.environ.get("WORKFRAME_E2E") == "1":
|
|
275
|
+
base = APP_BASE_URL.strip().lower()
|
|
276
|
+
if base.startswith("http://127.0.0.1") or base.startswith("http://localhost"):
|
|
277
|
+
return False
|
|
278
|
+
return APP_BASE_URL.strip().lower().startswith("https://")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
AUTH_TOKEN: str = ""
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def get_auth_token() -> str:
|
|
285
|
+
global AUTH_TOKEN
|
|
286
|
+
if not AUTH_TOKEN:
|
|
287
|
+
try:
|
|
288
|
+
AUTH_TOKEN = _require_auth_token()
|
|
289
|
+
except Exception as exc:
|
|
290
|
+
raise SystemExit(f"SECURE_MODE: failed to obtain auth token: {exc}") from exc
|
|
291
|
+
return AUTH_TOKEN
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def check_auth(request_handler) -> bool:
|
|
295
|
+
if DEV_LOCAL_UNSAFE:
|
|
296
|
+
return True
|
|
297
|
+
if getattr(request_handler, "auth_user", None):
|
|
298
|
+
return True
|
|
299
|
+
auth_header = request_handler.headers.get("Authorization", "")
|
|
300
|
+
if auth_header.startswith("Bearer "):
|
|
301
|
+
token = get_auth_token()
|
|
302
|
+
if token and hmac.compare_digest(auth_header[7:].strip(), token):
|
|
303
|
+
request_handler.auth_user = "service"
|
|
304
|
+
request_handler.auth_role = "owner" # type: ignore[attr-defined]
|
|
305
|
+
return True
|
|
306
|
+
sid = session_id_from_request(request_handler)
|
|
307
|
+
if sid:
|
|
308
|
+
session_info = _zk.validate_session_token(sid)
|
|
309
|
+
if session_info:
|
|
310
|
+
apply_session_user(request_handler, session_info["user_id"])
|
|
311
|
+
return True
|
|
312
|
+
return False
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def host_without_port(host: str) -> str:
|
|
316
|
+
host = (host or "").strip().lower()
|
|
317
|
+
if not host:
|
|
318
|
+
return ""
|
|
319
|
+
if host.startswith("["):
|
|
320
|
+
end = host.find("]")
|
|
321
|
+
return host[1:end] if end != -1 else host
|
|
322
|
+
if host.count(":") == 1:
|
|
323
|
+
return host.rsplit(":", 1)[0]
|
|
324
|
+
return host
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def allowed_hosts() -> set[str]:
|
|
328
|
+
configured = {host.strip().lower() for host in ALLOWED_HOSTS if host.strip()}
|
|
329
|
+
if configured:
|
|
330
|
+
return configured | DEFAULT_ALLOWED_HOSTS
|
|
331
|
+
return set(DEFAULT_ALLOWED_HOSTS)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def loopback_ui_origins() -> set[str]:
|
|
335
|
+
port = str(os.environ.get("WORKFRAME_UI_PORT", "18644") or "18644").strip()
|
|
336
|
+
return {f"http://{host}:{port}" for host in ("127.0.0.1", "localhost", "[::1]")}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def install_setup_route(method: str, path: str) -> bool:
|
|
340
|
+
if path == "/api/install/stack" and method in ("GET", "PATCH"):
|
|
341
|
+
return True
|
|
342
|
+
if path == "/api/install/status" and method == "GET":
|
|
343
|
+
return True
|
|
344
|
+
if path == "/api/install/publish-hints" and method == "GET":
|
|
345
|
+
return True
|
|
346
|
+
if path == "/api/install/url/test" and method in ("GET", "POST"):
|
|
347
|
+
return True
|
|
348
|
+
if method == "POST" and path in (
|
|
349
|
+
"/api/install/email/test",
|
|
350
|
+
"/api/install/complete",
|
|
351
|
+
"/api/install/setup-https",
|
|
352
|
+
):
|
|
353
|
+
return True
|
|
354
|
+
return False
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def secure_host_origin_ok(method: str, path: str, headers) -> bool:
|
|
358
|
+
if DEV_LOCAL_UNSAFE:
|
|
359
|
+
return True
|
|
360
|
+
if _srv()._install_window_open() and install_setup_route(method, path):
|
|
361
|
+
return True
|
|
362
|
+
return validate_host(headers) and validate_origin(headers)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def validate_host(headers) -> bool:
|
|
366
|
+
if DEV_LOCAL_UNSAFE:
|
|
367
|
+
return True
|
|
368
|
+
return host_without_port(headers.get("Host", "")) in allowed_hosts()
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def origin_host(origin: str) -> str:
|
|
372
|
+
parsed = urllib.parse.urlsplit(origin)
|
|
373
|
+
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
|
374
|
+
return ""
|
|
375
|
+
return parsed.hostname.lower()
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def origin_allowed(origin: str) -> bool:
|
|
379
|
+
if not origin:
|
|
380
|
+
return True
|
|
381
|
+
origin = origin.strip()
|
|
382
|
+
if origin in CORS_ALLOW_ORIGINS:
|
|
383
|
+
return True
|
|
384
|
+
if origin.rstrip("/") in loopback_ui_origins():
|
|
385
|
+
return True
|
|
386
|
+
if not CORS_ALLOW_ORIGINS:
|
|
387
|
+
return origin_host(origin) in allowed_hosts()
|
|
388
|
+
return False
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def validate_origin(headers) -> bool:
|
|
392
|
+
if DEV_LOCAL_UNSAFE:
|
|
393
|
+
return True
|
|
394
|
+
return origin_allowed(headers.get("Origin", ""))
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def validate_host_origin(headers) -> bool:
|
|
398
|
+
return validate_host(headers) and validate_origin(headers)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def admin_write_allowed(handler: BaseHTTPRequestHandler) -> bool:
|
|
402
|
+
if SECURE_MODE or not DEV_LOCAL_UNSAFE:
|
|
403
|
+
return True
|
|
404
|
+
return handler.headers.get("X-Workframe-Local", "").strip() == "1"
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def cors_origin_for(headers=None) -> str:
|
|
408
|
+
if DEV_LOCAL_UNSAFE:
|
|
409
|
+
return "*"
|
|
410
|
+
if not CORS_ALLOW_ORIGINS:
|
|
411
|
+
return ""
|
|
412
|
+
origin = ""
|
|
413
|
+
if headers is not None:
|
|
414
|
+
origin = headers.get("Origin", "").strip()
|
|
415
|
+
if origin:
|
|
416
|
+
return origin if origin in CORS_ALLOW_ORIGINS else ""
|
|
417
|
+
if len(CORS_ALLOW_ORIGINS) == 1:
|
|
418
|
+
return CORS_ALLOW_ORIGINS[0]
|
|
419
|
+
return ""
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def deployment_allows_sessionless_data_get(
|
|
423
|
+
*,
|
|
424
|
+
deployment_mode: str | None = None,
|
|
425
|
+
dev_local_unsafe: bool | None = None,
|
|
426
|
+
) -> bool:
|
|
427
|
+
mode = deployment_mode if deployment_mode is not None else _resolve_deployment_mode()
|
|
428
|
+
dev = DEV_LOCAL_UNSAFE if dev_local_unsafe is None else dev_local_unsafe
|
|
429
|
+
return route_registry.deployment_allows_sessionless_data_get(mode, dev_local_unsafe=dev)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def sessionless_get_allowed(
|
|
433
|
+
path: str,
|
|
434
|
+
*,
|
|
435
|
+
deployment_mode: str | None = None,
|
|
436
|
+
dev_local_unsafe: bool | None = None,
|
|
437
|
+
) -> bool:
|
|
438
|
+
mode = deployment_mode if deployment_mode is not None else _resolve_deployment_mode()
|
|
439
|
+
dev = DEV_LOCAL_UNSAFE if dev_local_unsafe is None else dev_local_unsafe
|
|
440
|
+
return route_registry.sessionless_get_allowed(path, deployment_mode=mode, dev_local_unsafe=dev)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def is_public_get(path: str) -> bool:
|
|
444
|
+
if path in ("", "/"):
|
|
445
|
+
return True
|
|
446
|
+
if path.startswith("/static/") or path.startswith("/assets/"):
|
|
447
|
+
return True
|
|
448
|
+
return sessionless_get_allowed(path)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def cookie_session_id(handler: BaseHTTPRequestHandler) -> str:
|
|
452
|
+
cookie_header = handler.headers.get("Cookie", "")
|
|
453
|
+
prefix = f"{_zk.session_cookie_name()}="
|
|
454
|
+
legacy_prefix = "wf_session="
|
|
455
|
+
for part in cookie_header.split(";"):
|
|
456
|
+
part = part.strip()
|
|
457
|
+
if part.startswith(prefix):
|
|
458
|
+
return part[len(prefix) :]
|
|
459
|
+
if part.startswith(legacy_prefix):
|
|
460
|
+
return part[len(legacy_prefix) :]
|
|
461
|
+
return ""
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def session_id_from_request(handler: BaseHTTPRequestHandler) -> str:
|
|
465
|
+
sid = cookie_session_id(handler)
|
|
466
|
+
if sid:
|
|
467
|
+
return sid
|
|
468
|
+
return handler.headers.get("X-Workframe-Session", "").strip()
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def workspace_role_for_user(user_id: str) -> str:
|
|
472
|
+
rank = {"owner": 3, "admin": 2, "member": 1}
|
|
473
|
+
best = "member"
|
|
474
|
+
try:
|
|
475
|
+
conn = _srv()._workframe_db()
|
|
476
|
+
rows = conn.execute(
|
|
477
|
+
"""
|
|
478
|
+
SELECT role FROM workspace_memberships
|
|
479
|
+
WHERE user_id = ? AND deleted_at IS NULL AND status = 'active'
|
|
480
|
+
""",
|
|
481
|
+
(user_id,),
|
|
482
|
+
).fetchall()
|
|
483
|
+
conn.close()
|
|
484
|
+
except Exception:
|
|
485
|
+
return best
|
|
486
|
+
for row in rows:
|
|
487
|
+
role = str(row["role"] or "member")
|
|
488
|
+
if rank.get(role, 0) > rank.get(best, 0):
|
|
489
|
+
best = role
|
|
490
|
+
return best
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def apply_session_user(handler: BaseHTTPRequestHandler, user_id: str) -> None:
|
|
494
|
+
handler.auth_user = user_id # type: ignore[attr-defined]
|
|
495
|
+
handler.auth_role = workspace_role_for_user(user_id) # type: ignore[attr-defined]
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def user_is_stack_operator(user_id: str) -> bool:
|
|
499
|
+
return workspace_role_for_user(user_id) in OWNER_ADMIN_ROLES
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def user_can_access_hermes_dashboard(user_id: str) -> bool:
|
|
503
|
+
if DEPLOYMENT_MODE == "single_user_local":
|
|
504
|
+
return True
|
|
505
|
+
if not user_id:
|
|
506
|
+
return False
|
|
507
|
+
return workspace_role_for_user(user_id) in OWNER_ADMIN_ROLES
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def authorize_request(handler: BaseHTTPRequestHandler) -> bool:
|
|
511
|
+
path = urllib.parse.urlparse(handler.path).path
|
|
512
|
+
method = handler.command
|
|
513
|
+
sid = session_id_from_request(handler)
|
|
514
|
+
|
|
515
|
+
def _validate(sid_token: str):
|
|
516
|
+
return _zk.validate_session_token(sid_token)
|
|
517
|
+
|
|
518
|
+
def _attach(user_id: str) -> None:
|
|
519
|
+
apply_session_user(handler, user_id)
|
|
520
|
+
|
|
521
|
+
return route_registry.authorize_request(
|
|
522
|
+
method,
|
|
523
|
+
path,
|
|
524
|
+
sid,
|
|
525
|
+
deployment_mode=_resolve_deployment_mode(),
|
|
526
|
+
dev_local_unsafe=DEV_LOCAL_UNSAFE,
|
|
527
|
+
install_window_open=_srv()._install_window_open(),
|
|
528
|
+
validate_session=_validate,
|
|
529
|
+
attach_user=_attach,
|
|
530
|
+
get_workspace_role=lambda: str(getattr(handler, "auth_role", "") or ""),
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def role_allows(handler: BaseHTTPRequestHandler, roles: set[str]) -> bool:
|
|
535
|
+
if not SECURE_MODE:
|
|
536
|
+
return True
|
|
537
|
+
return str(getattr(handler, "auth_role", "")) in roles
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def handler_is_active_workspace_member(handler: BaseHTTPRequestHandler, workspace_id: str = "") -> bool:
|
|
541
|
+
if not SECURE_MODE:
|
|
542
|
+
return True
|
|
543
|
+
if role_allows(handler, OWNER_ADMIN_ROLES):
|
|
544
|
+
return True
|
|
545
|
+
user_id = str(getattr(handler, "auth_user", "") or "").strip()
|
|
546
|
+
if not user_id:
|
|
547
|
+
return False
|
|
548
|
+
try:
|
|
549
|
+
conn = _srv()._workframe_db()
|
|
550
|
+
try:
|
|
551
|
+
ws_id = str(workspace_id or "").strip()
|
|
552
|
+
if ws_id:
|
|
553
|
+
return _srv()._workspace_member_role(conn, ws_id, user_id) is not None
|
|
554
|
+
row = conn.execute(
|
|
555
|
+
"""
|
|
556
|
+
SELECT 1 FROM workspace_memberships
|
|
557
|
+
WHERE user_id = ? AND deleted_at IS NULL AND status = 'active'
|
|
558
|
+
LIMIT 1
|
|
559
|
+
""",
|
|
560
|
+
(user_id,),
|
|
561
|
+
).fetchone()
|
|
562
|
+
return row is not None
|
|
563
|
+
finally:
|
|
564
|
+
conn.close()
|
|
565
|
+
except sqlite3.Error:
|
|
566
|
+
return False
|