e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
130 lines
4.9 KiB
Python
130 lines
4.9 KiB
Python
"""Logical resource grants for non-admin users."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from copy import deepcopy
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from .identity import get_user_by_id
|
|
from .paths import SYSTEM_ROOT, ensure_system_dirs
|
|
|
|
GRANTS_DIR = SYSTEM_ROOT / "grants"
|
|
|
|
|
|
def empty_grant(user_id: str) -> dict[str, Any]:
|
|
return {
|
|
"version": 2,
|
|
"user_id": user_id,
|
|
"models": {"llm": []},
|
|
"knowledge_bases": [],
|
|
"skills": [],
|
|
# Partners an admin has assigned to this user. Partners stay
|
|
# admin-managed (the /api/v1/partners CRUD router is admin-gated); a
|
|
# grant only lets the user *see and consult* the named partners — same
|
|
# shape as ``skills`` (``[{"partner_id": ...}]``).
|
|
"partners": [],
|
|
# Tool whitelists share the partner-config semantics for built-ins:
|
|
# ``enabled_tools=None`` means "default" (every tool in the pool),
|
|
# ``[]`` means none, a list is an explicit whitelist. MCP tools can
|
|
# proxy host-side capabilities, so non-admin runtime access treats
|
|
# ``mcp_tools=None`` as deny-by-default until an admin grants explicit
|
|
# names. ``exec_enabled`` is a tri-state override on top of the
|
|
# deployment exec policy: ``None`` follows the policy, ``False`` always
|
|
# denies, ``True`` is only honored where the sandbox can actually
|
|
# isolate users (SYSTEM isolation).
|
|
"enabled_tools": None,
|
|
"mcp_tools": None,
|
|
"exec_enabled": None,
|
|
}
|
|
|
|
|
|
def _normalize_tool_list(value: Any) -> list[str] | None:
|
|
if value is None:
|
|
return None
|
|
if not isinstance(value, list):
|
|
return None
|
|
return [str(item).strip() for item in value if str(item).strip()]
|
|
|
|
|
|
def grant_path(user_id: str) -> Path:
|
|
ensure_system_dirs()
|
|
return GRANTS_DIR / f"{user_id}.json"
|
|
|
|
|
|
def normalize_grant(user_id: str, payload: dict[str, Any] | None) -> dict[str, Any]:
|
|
"""Coerce any stored/submitted grant payload into the v2 shape.
|
|
|
|
v1 grants normalize losslessly for everything that was ever enforced:
|
|
``models.embedding`` / ``models.search`` / ``spaces`` had no runtime
|
|
consumers and are dropped; absent v2 fields default to unrestricted.
|
|
"""
|
|
base = empty_grant(user_id)
|
|
if not isinstance(payload, dict):
|
|
return base
|
|
base["user_id"] = user_id
|
|
models = payload.get("models") if isinstance(payload.get("models"), dict) else {}
|
|
items = models.get("llm") if isinstance(models, dict) else []
|
|
if not isinstance(items, list):
|
|
items = []
|
|
base["models"]["llm"] = [dict(item) for item in items if isinstance(item, dict)]
|
|
for key in ("knowledge_bases", "skills", "partners"):
|
|
values = payload.get(key) if isinstance(payload.get(key), list) else []
|
|
base[key] = [dict(item) for item in values if isinstance(item, dict)]
|
|
for key in ("enabled_tools", "mcp_tools"):
|
|
base[key] = _normalize_tool_list(payload.get(key))
|
|
exec_enabled = payload.get("exec_enabled")
|
|
base["exec_enabled"] = bool(exec_enabled) if isinstance(exec_enabled, bool) else None
|
|
return base
|
|
|
|
|
|
def load_grant(user_id: str) -> dict[str, Any]:
|
|
path = grant_path(user_id)
|
|
if not path.exists():
|
|
return empty_grant(user_id)
|
|
try:
|
|
return normalize_grant(user_id, json.loads(path.read_text(encoding="utf-8")))
|
|
except Exception:
|
|
return empty_grant(user_id)
|
|
|
|
|
|
def save_grant(user_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
user_record = get_user_by_id(user_id)
|
|
if user_record is None:
|
|
raise ValueError(f"Unknown user id: {user_id}")
|
|
_username, record = user_record
|
|
if str(record.get("role") or "user") == "admin":
|
|
raise ValueError("Admin users use the main workspace and cannot receive assignments.")
|
|
grant = normalize_grant(user_id, payload)
|
|
validate_grant(grant)
|
|
path = grant_path(user_id)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(grant, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
return grant
|
|
|
|
|
|
def validate_grant(grant: dict[str, Any]) -> None:
|
|
"""Reject accidental secret/path material in grants.
|
|
|
|
Grants carry logical ids only. Runtime resolution happens server-side.
|
|
"""
|
|
forbidden = {"api_key", "secret", "password", "token", "path", "base_url"}
|
|
|
|
def walk(value: Any, trail: str = "grant") -> None:
|
|
if isinstance(value, dict):
|
|
for key, child in value.items():
|
|
lowered = str(key).lower()
|
|
if lowered in forbidden or lowered.endswith("_key"):
|
|
raise ValueError(f"Grants must not contain secret/path field: {trail}.{key}")
|
|
walk(child, f"{trail}.{key}")
|
|
elif isinstance(value, list):
|
|
for index, child in enumerate(value):
|
|
walk(child, f"{trail}[{index}]")
|
|
|
|
walk(grant)
|
|
|
|
|
|
def public_grant(user_id: str) -> dict[str, Any]:
|
|
return deepcopy(load_grant(user_id))
|