blob: b591a773036327242e1549ff9a3f1ba3b39d14f1 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
from __future__ import annotations
from collections.abc import Mapping
import hashlib
import json
import os
from pathlib import Path
import re
from threading import RLock
from typing import Any
from iotdb_mcp_server.policy_approval import PolicyApprovalStore
_DEFAULT_SERVER_NAME = "iotdb"
_SHELL_DEFAULT_RE = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_]*):-([^}]*)\}$")
_SENSITIVE_KEY_RE = re.compile(
r"(PASSWORD|SECRET|TOKEN|API_KEY|ACCESS_KEY)", re.IGNORECASE
)
_WITHHELD_ENV_KEYS = frozenset({"TIMESEEK_IOTDB_TARGETS_JSON"})
_SESSION_POLICY_LOCK = RLock()
_SESSION_POLICY: dict[str, str] = {}
_DEPLOYMENT_POLICY: dict[str, str] | None = None
_DEPLOYMENT_ENV: dict[str, str] = {}
_DEPLOYMENT_CONFIG_PATH = ""
_APPROVAL_MODE = "require"
_APPROVAL_STORE: PolicyApprovalStore | None = None
_POLICY_REVISION = 0
_ENFORCEMENT_POLICY_KEYS = frozenset(
{
"TIMESEEK_MCP_PERMISSION_ENFORCEMENT",
"IOTDB_STRICT_PERMISSION_ENFORCEMENT",
}
)
_POLICY_KEYS = frozenset(
{
"IOTDB_ENABLE_METADATA_QUERY",
"IOTDB_METADATA_ALLOWED_USERS",
"IOTDB_ENABLE_DATABASE_DDL",
"IOTDB_DATABASE_DDL_ALLOWED_USERS",
"IOTDB_REQUIRE_DROP_CONFIRM",
"IOTDB_ENABLE_TABLE_DDL",
"IOTDB_TABLE_DDL_ALLOWED_USERS",
"IOTDB_REQUIRE_TABLE_DROP_CONFIRM",
"IOTDB_ENABLE_TIMESERIES_DDL",
"IOTDB_TIMESERIES_DDL_ALLOWED_USERS",
"IOTDB_REQUIRE_TIMESERIES_DROP_CONFIRM",
"IOTDB_ENABLE_TTL_SQL",
"IOTDB_TTL_ALLOWED_USERS",
"IOTDB_REQUIRE_TTL_UNSET_CONFIRM",
"IOTDB_ENABLE_WRITE_DML",
"IOTDB_WRITE_ALLOWED_USERS",
"IOTDB_REQUIRE_DELETE_CONFIRM",
"IOTDB_ENABLE_SQL_DRIVER",
"IOTDB_SQL_DRIVER_ALLOWED_USERS",
"IOTDB_SQL_DRIVER_MODE",
"IOTDB_SQL_DRIVER_REQUIRE_DESTRUCTIVE_CONFIRM",
"IOTDB_SQL_DRIVER_EXTRA_READONLY_PREFIXES",
"IOTDB_SQL_DRIVER_EXTRA_DDL_PREFIXES",
"IOTDB_SQL_DRIVER_EXTRA_FULL_PREFIXES",
"IOTDB_ENABLE_MODEL_MANAGEMENT",
"IOTDB_MODEL_ALLOWED_USERS",
"IOTDB_REQUIRE_MODEL_DESTRUCTIVE_CONFIRM",
"TIMESEEK_MCP_PERMISSION_ENFORCEMENT",
"IOTDB_STRICT_PERMISSION_ENFORCEMENT",
}
)
# Prefix changes can reclassify writes as reads, so they are administrator-only.
_ADMIN_POLICY_KEYS = _ENFORCEMENT_POLICY_KEYS | frozenset(
key for key in _POLICY_KEYS if "_EXTRA_" in key
)
_BOOL_POLICY_KEYS = frozenset(
key
for key in _POLICY_KEYS
if key.startswith("IOTDB_ENABLE_")
or "CONFIRM" in key
or key == "IOTDB_STRICT_PERMISSION_ENFORCEMENT"
)
_MODE_RANK = {"readonly": 0, "ddl": 1, "full": 2}
_FULL_PERMISSION_DEFAULTS = {
"IOTDB_ENABLE_METADATA_QUERY": "true",
"IOTDB_METADATA_ALLOWED_USERS": "*",
"IOTDB_ENABLE_DATABASE_DDL": "true",
"IOTDB_DATABASE_DDL_ALLOWED_USERS": "*",
"IOTDB_REQUIRE_DROP_CONFIRM": "true",
"IOTDB_ENABLE_TABLE_DDL": "true",
"IOTDB_TABLE_DDL_ALLOWED_USERS": "*",
"IOTDB_REQUIRE_TABLE_DROP_CONFIRM": "true",
"IOTDB_ENABLE_TIMESERIES_DDL": "true",
"IOTDB_TIMESERIES_DDL_ALLOWED_USERS": "*",
"IOTDB_REQUIRE_TIMESERIES_DROP_CONFIRM": "true",
"IOTDB_ENABLE_TTL_SQL": "true",
"IOTDB_TTL_ALLOWED_USERS": "*",
"IOTDB_REQUIRE_TTL_UNSET_CONFIRM": "true",
"IOTDB_ENABLE_WRITE_DML": "true",
"IOTDB_WRITE_ALLOWED_USERS": "*",
"IOTDB_REQUIRE_DELETE_CONFIRM": "true",
"IOTDB_ENABLE_SQL_DRIVER": "true",
"IOTDB_SQL_DRIVER_ALLOWED_USERS": "*",
"IOTDB_SQL_DRIVER_MODE": "full",
"IOTDB_SQL_DRIVER_REQUIRE_DESTRUCTIVE_CONFIRM": "true",
"IOTDB_ENABLE_MODEL_MANAGEMENT": "true",
"IOTDB_MODEL_ALLOWED_USERS": "*",
"IOTDB_REQUIRE_MODEL_DESTRUCTIVE_CONFIRM": "true",
}
_ENFORCEMENT_DEFAULTS = {
"TIMESEEK_MCP_PERMISSION_ENFORCEMENT": "advisory",
"IOTDB_STRICT_PERMISSION_ENFORCEMENT": "false",
}
_PRESET_POLICIES = {
"full": dict(_FULL_PERMISSION_DEFAULTS),
"ddl": {
**_FULL_PERMISSION_DEFAULTS,
"IOTDB_SQL_DRIVER_MODE": "ddl",
"IOTDB_ENABLE_WRITE_DML": "false",
},
"readonly": {
**_FULL_PERMISSION_DEFAULTS,
"IOTDB_ENABLE_DATABASE_DDL": "false",
"IOTDB_ENABLE_TABLE_DDL": "false",
"IOTDB_ENABLE_TIMESERIES_DDL": "false",
"IOTDB_ENABLE_TTL_SQL": "false",
"IOTDB_ENABLE_WRITE_DML": "false",
"IOTDB_ENABLE_MODEL_MANAGEMENT": "false",
"IOTDB_SQL_DRIVER_MODE": "readonly",
},
}
def _candidate_mcp_config_paths() -> list[Path]:
candidates: list[Path] = []
explicit = os.getenv("TIMESEEK_MCP_CONFIG_PATH")
if explicit:
candidates.append(Path(explicit).expanduser())
timeseek_root = os.getenv("TIMESEEK_ROOT")
if timeseek_root:
root = Path(timeseek_root).expanduser()
candidates.extend(
[
root / ".mcp.json",
root / "timeseek-codex-multi-plugin" / ".mcp.json",
root / "timeseek-codex-single-plugin" / ".mcp.json",
]
)
repo_root = os.getenv("REPO_ROOT") or os.getenv("TIMESEEK_REPO_ROOT")
if repo_root:
root = Path(repo_root).expanduser()
candidates.extend(
[
root / ".mcp.json",
root / "timeseek_codex" / "timeseek-codex-multi-plugin" / ".mcp.json",
root / "timeseek_codex" / "timeseek-codex-single-plugin" / ".mcp.json",
root / "timeseek_cc" / ".mcp.json",
]
)
cwd = Path.cwd()
candidates.extend(
[
cwd / ".mcp.json",
cwd / "timeseek_codex" / "timeseek-codex-multi-plugin" / ".mcp.json",
cwd / "timeseek_codex" / "timeseek-codex-single-plugin" / ".mcp.json",
cwd / "timeseek_cc" / ".mcp.json",
]
)
deduped: list[Path] = []
seen: set[str] = set()
for candidate in candidates:
key = str(candidate)
if key not in seen:
seen.add(key)
deduped.append(candidate)
return deduped
def _expand_config_value(value: Any) -> str:
text = str(value)
match = _SHELL_DEFAULT_RE.fullmatch(text)
if not match:
return text
name, default = match.groups()
current = os.getenv(name)
return current if current not in (None, "") else default
def _load_mcp_env() -> tuple[dict[str, str], str]:
server_name = os.getenv("TIMESEEK_MCP_SERVER_NAME", _DEFAULT_SERVER_NAME)
for path in _candidate_mcp_config_paths():
if not path.exists():
continue
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
continue
server = data.get("mcpServers", {}).get(server_name, {})
env = server.get("env", {})
if isinstance(env, dict):
return {
str(key): _expand_config_value(value) for key, value in env.items()
}, str(path)
return {}, ""
def _normalize_policy_value(key: str, value: Any) -> str:
text = _coerce_policy_value(value).strip()
if key in _BOOL_POLICY_KEYS:
if text.lower() in {"1", "true", "yes", "on"}:
return "true"
if text.lower() in {"0", "false", "no", "off"}:
return "false"
raise ValueError(f"{key} must be a boolean.")
if key == "IOTDB_SQL_DRIVER_MODE":
if text.lower() not in _MODE_RANK:
raise ValueError(f"{key} must be readonly, ddl, or full.")
return text.lower()
if key == "TIMESEEK_MCP_PERMISSION_ENFORCEMENT":
if text.lower() not in {"advisory", "strict"}:
raise ValueError(f"{key} must be advisory or strict.")
return text.lower()
items = {item.strip() for item in text.split(",") if item.strip()}
if key.endswith("_ALLOWED_USERS"):
return "*" if "*" in items else ",".join(sorted(items))
return ",".join(sorted(item.upper() for item in items))
def initialize_runtime_policy() -> None:
"""Freeze operator policy once, before tools are registered. No MCP reload API."""
global _DEPLOYMENT_POLICY, _DEPLOYMENT_ENV, _DEPLOYMENT_CONFIG_PATH
global _APPROVAL_MODE, _APPROVAL_STORE
with _SESSION_POLICY_LOCK:
if _DEPLOYMENT_POLICY is not None:
return
env, path = _load_mcp_env()
# Explicit process environment wins over config discovery, including empty
# allowlists. Malformed policy fails startup rather than enabling access.
source = {**env, **os.environ}
defaults = {**_FULL_PERMISSION_DEFAULTS, **_ENFORCEMENT_DEFAULTS}
deployment = {
key: _normalize_policy_value(key, source.get(key, defaults.get(key, "")))
for key in _POLICY_KEYS
}
mode = (
source.get("IOTDB_SESSION_POLICY_APPROVAL_MODE", "require").strip().lower()
)
if mode not in {"require", "allow"}:
raise ValueError(
"IOTDB_SESSION_POLICY_APPROVAL_MODE must be require or allow."
)
directory = source.get("IOTDB_SESSION_POLICY_APPROVAL_DIR", "")
store = PolicyApprovalStore(Path(directory).expanduser()) if directory else None
_DEPLOYMENT_ENV = env
_DEPLOYMENT_CONFIG_PATH = path
_APPROVAL_MODE = mode
_APPROVAL_STORE = store
_DEPLOYMENT_POLICY = deployment
def dynamic_getenv(name: str, default: str | None = None) -> str | None:
if name in _POLICY_KEYS:
initialize_runtime_policy()
with _SESSION_POLICY_LOCK:
assert _DEPLOYMENT_POLICY is not None
return _SESSION_POLICY.get(name, _DEPLOYMENT_POLICY[name])
# Non-policy operational settings retain their existing lookup behavior.
env, _ = _load_mcp_env()
value = env.get(name)
if value not in (None, ""):
return value
value = os.getenv(name)
if value not in (None, ""):
return value
return _FULL_PERMISSION_DEFAULTS.get(
name,
_ENFORCEMENT_DEFAULTS.get(name, default),
)
def dynamic_env_bool(name: str, default: bool) -> bool:
value = dynamic_getenv(name)
if value is None:
return default
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off", ""}:
return False
return default
def permission_enforcement_mode() -> str:
if dynamic_env_bool("IOTDB_STRICT_PERMISSION_ENFORCEMENT", False):
return "strict"
mode = (
(
dynamic_getenv("TIMESEEK_MCP_PERMISSION_ENFORCEMENT", "advisory")
or "advisory"
)
.strip()
.lower()
)
if mode not in {"advisory", "strict"}:
return "advisory"
return mode
def strict_permission_enforcement() -> bool:
return permission_enforcement_mode() == "strict"
def _redact_env(env: dict[str, str]) -> dict[str, str]:
return {
key: (
"***"
if key in _WITHHELD_ENV_KEYS or _SENSITIVE_KEY_RE.search(key)
else value
)
for key, value in env.items()
}
def _coerce_policy_value(value: Any) -> str:
if isinstance(value, bool):
return "true" if value else "false"
return str(value)
def _validate_policy_keys(policy: Mapping[str, Any]) -> None:
unknown = sorted(str(key) for key in policy if str(key) not in _POLICY_KEYS)
if unknown:
raise ValueError(
"Unsupported session policy key(s): "
+ ", ".join(unknown)
+ ". Use get_iotdb_session_policy to inspect supported keys."
)
admin = sorted(str(key) for key in policy if str(key) in _ADMIN_POLICY_KEYS)
if admin:
raise PermissionError(
"Administrator-only policy key(s): "
+ ", ".join(admin)
+ ". Change protected deployment configuration and restart the server."
)
def session_policy_for_preset(preset: str) -> dict[str, str]:
normalized = preset.strip().lower()
try:
return dict(_PRESET_POLICIES[normalized])
except KeyError as exc:
raise ValueError(
"Unsupported policy preset. Expected one of: full, ddl, readonly."
) from exc
def supported_policy_keys() -> list[str]:
return sorted(_POLICY_KEYS - _ADMIN_POLICY_KEYS)
def _no_broader(key: str, candidate: str, limit: str) -> bool:
if candidate == limit:
return True
if key.startswith("IOTDB_ENABLE_"):
return candidate == "false"
if "CONFIRM" in key:
return candidate == "true"
if key == "IOTDB_SQL_DRIVER_MODE":
return _MODE_RANK[candidate] <= _MODE_RANK[limit]
if key.endswith("_ALLOWED_USERS"):
candidate_users = {item for item in candidate.split(",") if item}
limit_users = {item for item in limit.split(",") if item}
return "*" in limit_users or candidate_users <= limit_users
return False
def _apply_session_candidate(candidate: dict[str, str]) -> dict[str, Any]:
"""Validate, authorize, and commit under one lock, including reset/replace."""
global _POLICY_REVISION
assert _DEPLOYMENT_POLICY is not None
before = {**_DEPLOYMENT_POLICY, **_SESSION_POLICY}
after = {**_DEPLOYMENT_POLICY, **candidate}
above_ceiling = [
key
for key in after
if not _no_broader(key, after[key], _DEPLOYMENT_POLICY[key])
]
if above_ceiling:
raise PermissionError(
"Deployment permission ceiling exceeded: "
+ ", ".join(sorted(above_ceiling))
+ ". Administrator must change protected configuration and restart."
)
changes = {
key: {"before": before[key], "after": after[key]}
for key in sorted(after)
if before[key] != after[key]
}
widened = [key for key in changes if not _no_broader(key, after[key], before[key])]
if widened and _APPROVAL_MODE == "require":
if _APPROVAL_STORE is None:
return {
**dynamic_policy_snapshot(),
"status": "approval_unavailable",
"applied": False,
"requested_changes": changes,
"message": "No administrator approval directory configured; policy unchanged.",
}
fingerprint = hashlib.sha256(
json.dumps(_DEPLOYMENT_POLICY, sort_keys=True).encode()
).hexdigest()
approval = _APPROVAL_STORE.authorize(
{
"revision": _POLICY_REVISION,
"deployment_fingerprint": fingerprint,
"changes": changes,
"widened_keys": widened,
}
)
if approval["status"] != "approved":
return {
**dynamic_policy_snapshot(),
**approval,
"applied": False,
"message": "Policy unchanged. Administrator approval is required before retrying.",
}
_SESSION_POLICY.clear()
_SESSION_POLICY.update(candidate)
if changes:
_POLICY_REVISION += 1
return {**dynamic_policy_snapshot(), "status": "applied", "applied": True}
def set_session_policy(
policy: Mapping[str, Any] | None = None,
*,
preset: str | None = None,
replace: bool = False,
) -> dict[str, Any]:
initialize_runtime_policy()
with _SESSION_POLICY_LOCK:
assert _DEPLOYMENT_POLICY is not None
updates: dict[str, str] = {}
if preset:
# Presets are permission caps, intersected with operator restrictions.
for key, value in session_policy_for_preset(preset).items():
ceiling = _DEPLOYMENT_POLICY[key]
updates[key] = value if _no_broader(key, value, ceiling) else ceiling
if policy:
_validate_policy_keys(policy)
updates.update(
{
str(key): _normalize_policy_value(str(key), value)
for key, value in policy.items()
}
)
candidate = {} if replace else dict(_SESSION_POLICY)
candidate.update(updates)
return _apply_session_candidate(candidate)
def reset_session_policy(keys: list[str] | None = None) -> dict[str, Any]:
initialize_runtime_policy()
if keys is not None:
_validate_policy_keys({key: "" for key in keys})
with _SESSION_POLICY_LOCK:
candidate = {} if keys is None else dict(_SESSION_POLICY)
if keys is not None:
for key in keys:
candidate.pop(key, None)
return _apply_session_candidate(candidate)
def dynamic_policy_snapshot() -> dict[str, Any]:
initialize_runtime_policy()
with _SESSION_POLICY_LOCK:
assert _DEPLOYMENT_POLICY is not None
return {
"mcp_config_path": _DEPLOYMENT_CONFIG_PATH,
"session_policy": dict(_SESSION_POLICY),
"mcp_env": _redact_env(_DEPLOYMENT_ENV),
"defaults": {**_FULL_PERMISSION_DEFAULTS, **_ENFORCEMENT_DEFAULTS},
"deployment_policy": dict(_DEPLOYMENT_POLICY),
"effective_policy": {**_DEPLOYMENT_POLICY, **_SESSION_POLICY},
"supported_keys": supported_policy_keys(),
"administrator_only_keys": sorted(_ADMIN_POLICY_KEYS),
"approval_mode": _APPROVAL_MODE,
"approval_available": _APPROVAL_STORE is not None,
"policy_revision": _POLICY_REVISION,
"presets": sorted(_PRESET_POLICIES),
}