feat: allow own principal profile session access
parent
1291b958bf
commit
af62a3ce30
|
|
@ -17835,6 +17835,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
3. The active profile (the multiplexer's own home).
|
||||
"""
|
||||
from hermes_cli.profiles import (
|
||||
create_profile,
|
||||
get_active_profile_name,
|
||||
get_profile_dir,
|
||||
profile_exists,
|
||||
|
|
@ -17855,8 +17856,27 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
name = get_active_profile_name() or "default"
|
||||
|
||||
profile_dir = get_profile_dir(name)
|
||||
# Warn if an explicit profile doesn't exist on disk
|
||||
# Principal-routed profiles are part of the isolation boundary. If a
|
||||
# verified principal resolves to a profile name that has not been
|
||||
# materialized on disk yet, create it now instead of collapsing the
|
||||
# request back into the global/default HERMES_HOME.
|
||||
if explicit_profile and not profile_exists(name):
|
||||
if name.startswith("principal_"):
|
||||
try:
|
||||
create_profile(name, no_alias=True)
|
||||
except FileExistsError:
|
||||
pass
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to auto-create principal profile %r for source %s/%s (guild_id=%s)",
|
||||
explicit_profile,
|
||||
source.platform.value,
|
||||
source.chat_id,
|
||||
getattr(source, "guild_id", None),
|
||||
exc_info=True,
|
||||
)
|
||||
if profile_exists(name):
|
||||
return profile_dir
|
||||
logger.warning(
|
||||
"Profile %r does not exist for source %s/%s (guild_id=%s), "
|
||||
"falling back to global HERMES_HOME",
|
||||
|
|
|
|||
|
|
@ -665,10 +665,26 @@ def _dashboard_identity_scope(request: Request) -> dict[str, Any]:
|
|||
return scope
|
||||
|
||||
|
||||
def _allowed_profile_names_for_scope(scope: dict[str, Any]) -> set[str]:
|
||||
allowed = {"", "default"}
|
||||
try:
|
||||
from gateway.principal_profiles import principal_profile_name
|
||||
|
||||
for principal_id in scope.get("principal_ids") or []:
|
||||
try:
|
||||
allowed.add(principal_profile_name(str(principal_id)))
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
return allowed
|
||||
|
||||
|
||||
def _enforce_dashboard_profile_access(scope: dict[str, Any], profile: Optional[str]) -> None:
|
||||
if scope.get("admin"):
|
||||
return
|
||||
if profile and str(profile).strip() not in {"", "default"}:
|
||||
profile_name = str(profile or "").strip()
|
||||
if profile_name not in _allowed_profile_names_for_scope(scope):
|
||||
raise HTTPException(status_code=403, detail="forbidden")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,16 @@ class TestProfilesSessionsAccess:
|
|||
web_server.get_profiles_sessions(_request("user@bremen.com.tw"))
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
def test_non_admin_may_access_own_principal_profile(self):
|
||||
scope = {"admin": False, "principal_ids": ["p-123"]}
|
||||
web_server._enforce_dashboard_profile_access(scope, "principal_p123")
|
||||
|
||||
def test_non_admin_cannot_access_other_principal_profile(self):
|
||||
scope = {"admin": False, "principal_ids": ["p-123"]}
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
web_server._enforce_dashboard_profile_access(scope, "principal_other999")
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
def test_admin_allowed_to_reach_listing(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(web_server, "_dashboard_identity_scope", lambda request: {"admin": True})
|
||||
monkeypatch.setattr(web_server, "_cron_profile_home", lambda profile: (profile or "default", tmp_path))
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from tools.session_search_tool import (
|
|||
_format_timestamp,
|
||||
session_search,
|
||||
)
|
||||
import tools.session_search_tool as session_search_tool
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -338,6 +339,36 @@ class TestAccessControl:
|
|||
assert result["success"] is False
|
||||
assert "只有 admin 權限可跨 profile" in result.get("error", "")
|
||||
|
||||
def test_non_admin_may_read_own_principal_profile(self, db, monkeypatch):
|
||||
class _FakeStore:
|
||||
def get_principal_context(self, source):
|
||||
return {"principal_id": "p-123"}
|
||||
|
||||
monkeypatch.setattr("gateway.user_verification.GatewayUserStore", lambda: _FakeStore())
|
||||
tokens = set_session_vars(platform="telegram", source="telegram", user_id="alice", chat_id="chat-a")
|
||||
try:
|
||||
result = json.loads(session_search(session_id="some-session", profile="principal_p123", db=db))
|
||||
finally:
|
||||
clear_session_vars(tokens)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "只有 admin 權限可跨 profile" not in result.get("error", "")
|
||||
|
||||
def test_non_admin_cannot_read_other_principal_profile(self, db, monkeypatch):
|
||||
class _FakeStore:
|
||||
def get_principal_context(self, source):
|
||||
return {"principal_id": "p-123"}
|
||||
|
||||
monkeypatch.setattr("gateway.user_verification.GatewayUserStore", lambda: _FakeStore())
|
||||
tokens = set_session_vars(platform="telegram", source="telegram", user_id="alice", chat_id="chat-a")
|
||||
try:
|
||||
result = json.loads(session_search(session_id="some-session", profile="principal_other999", db=db))
|
||||
finally:
|
||||
clear_session_vars(tokens)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "只有 admin 權限可跨 profile" in result.get("error", "")
|
||||
|
||||
def test_admin_can_read_other_users_session(self, db):
|
||||
db.create_session("alice", source="telegram", user_id="alice", chat_id="chat-a")
|
||||
db.append_message("alice", role="user", content="Alice private note")
|
||||
|
|
|
|||
|
|
@ -120,11 +120,43 @@ def _session_visible_to_requester(meta: Dict[str, Any], ctx: Dict[str, str]) ->
|
|||
return True
|
||||
|
||||
|
||||
def _allowed_profile_names_for_context(ctx: Dict[str, str]) -> set[str]:
|
||||
allowed = {"", "default"}
|
||||
try:
|
||||
from gateway.principal_profiles import principal_profile_name
|
||||
from gateway.session import SessionSource
|
||||
from gateway.user_verification import GatewayUserStore
|
||||
|
||||
platform = str(ctx.get("platform") or ctx.get("source") or "").strip()
|
||||
user_id = str(ctx.get("user_id") or ctx.get("chat_id") or "").strip()
|
||||
chat_id = str(ctx.get("chat_id") or user_id).strip()
|
||||
if not platform or not chat_id:
|
||||
return allowed
|
||||
source = SessionSource(
|
||||
platform=platform,
|
||||
chat_id=chat_id,
|
||||
user_id=user_id or chat_id,
|
||||
user_name="",
|
||||
chat_type="dm",
|
||||
thread_id=str(ctx.get("thread_id") or "").strip() or None,
|
||||
)
|
||||
context = GatewayUserStore().get_principal_context(source)
|
||||
principal_id = str((context or {}).get("principal_id") or "").strip()
|
||||
if principal_id:
|
||||
allowed.add(principal_profile_name(principal_id))
|
||||
except Exception:
|
||||
pass
|
||||
return allowed
|
||||
|
||||
|
||||
def _authorize_profile_access(profile: Optional[str], ctx: Dict[str, str]) -> Optional[str]:
|
||||
if profile is None or not str(profile).strip():
|
||||
return None
|
||||
if _is_admin_access(ctx) or not _is_scoped_gateway_session(ctx):
|
||||
return None
|
||||
requested = str(profile).strip()
|
||||
if requested in _allowed_profile_names_for_context(ctx):
|
||||
return None
|
||||
return "只有 admin 權限可跨 profile 讀取對話/工作紀錄。"
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue