feat: complete S49 multi-tenant boundary model

This commit is contained in:
rookiestar28
2026-03-07 02:59:04 +08:00
parent e0bfe64530
commit 44ef61ca10
29 changed files with 2107 additions and 714 deletions
+179 -107
View File
@@ -14,6 +14,7 @@ try:
from ..services.approvals.service import get_approval_service
from ..services.audit import emit_audit_event
from ..services.management_query import bounded_scan_collect, normalize_limit_offset
from ..services.tenant_context import TenantBoundaryError, request_tenant_scope
from ..services.webhook_auth import AuthError
except ImportError:
# Fallback for ComfyUI's non-package loader or ad-hoc imports.
@@ -25,6 +26,10 @@ except ImportError:
bounded_scan_collect,
normalize_limit_offset,
)
from services.tenant_context import ( # type: ignore
TenantBoundaryError,
request_tenant_scope,
)
from services.webhook_auth import AuthError
logger = logging.getLogger("ComfyUI-OpenClaw.api.approvals")
@@ -129,36 +134,52 @@ class ApprovalHandlers:
{"error": f"Invalid status: {status_filter}"}, status=400
)
# Get approvals
# R95: bounded scan window protects API serialization path and keeps
# malformed-record behavior deterministic without swallowing service errors.
scan_cap = max(page.offset + page.limit + 200, page.limit * 10)
approvals = self._service.list_all(
status=status,
limit=min(scan_cap, 5000),
offset=0,
)
page_result = bounded_scan_collect(
approvals,
skip=page.offset,
take=page.limit,
scan_cap=min(scan_cap, 5000),
serializer=lambda a: a.to_dict(),
)
token_info = resolve_token_info(request)
try:
with request_tenant_scope(
request=request,
token_info=token_info,
allow_default_when_missing=True,
) as tenant:
# Get approvals
# R95: bounded scan window protects API serialization path and keeps
# malformed-record behavior deterministic without swallowing service errors.
scan_cap = max(page.offset + page.limit + 200, page.limit * 10)
approvals = self._service.list_all(
status=status,
limit=min(scan_cap, 5000),
offset=0,
tenant_id=tenant.tenant_id,
)
page_result = bounded_scan_collect(
approvals,
skip=page.offset,
take=page.limit,
scan_cap=min(scan_cap, 5000),
serializer=lambda a: a.to_dict(),
)
return web.json_response(
{
"approvals": page_result.items,
"count": len(page_result.items),
"pending_count": self._service.count_pending(),
"pagination": {
"limit": page.limit,
"offset": page.offset,
"warnings": page.warnings,
},
"scan": page_result.to_dict(),
}
)
return web.json_response(
{
"tenant_id": tenant.tenant_id,
"approvals": page_result.items,
"count": len(page_result.items),
"pending_count": self._service.count_pending(
tenant_id=tenant.tenant_id
),
"pagination": {
"limit": page.limit,
"offset": page.offset,
"warnings": page.warnings,
},
"scan": page_result.to_dict(),
}
)
except TenantBoundaryError as exc:
return web.json_response(
{"error": exc.code, "message": str(exc)},
status=403,
)
async def get_approval(self, request: web.Request) -> web.Response:
"""GET /moltbot/approvals/{approval_id} - Get a single approval."""
@@ -186,12 +207,28 @@ class ApprovalHandlers:
return web.json_response({"error": "Unauthorized"}, status=403)
approval_id = request.match_info.get("approval_id", "")
approval = self._service.get(approval_id)
token_info = resolve_token_info(request)
try:
with request_tenant_scope(
request=request,
token_info=token_info,
allow_default_when_missing=True,
) as tenant:
approval = self._service.get(approval_id, tenant_id=tenant.tenant_id)
if not approval:
return web.json_response({"error": "Approval not found"}, status=404)
if not approval:
return web.json_response(
{"error": "Approval not found"}, status=404
)
return web.json_response({"approval": approval.to_dict()})
return web.json_response(
{"tenant_id": tenant.tenant_id, "approval": approval.to_dict()}
)
except TenantBoundaryError as exc:
return web.json_response(
{"error": exc.code, "message": str(exc)},
status=403,
)
async def approve_request(self, request: web.Request) -> web.Response:
"""POST /moltbot/approvals/{approval_id}/approve - Approve and execute request."""
@@ -230,67 +267,88 @@ class ApprovalHandlers:
except Exception:
pass # No body is fine
token_info = resolve_token_info(request)
try:
# First approve the request
approval = self._service.approve(approval_id, actor=actor)
logger.info(f"Approved request: {approval_id}")
result = {
"approved": True,
"approval": approval.to_dict(),
}
# Execute if requested and submit_fn is available
if auto_execute and self._submit_fn:
try:
from .triggers import execute_approved_trigger
exec_result = await execute_approved_trigger(
approval_id=approval_id,
submit_fn=self._submit_fn,
)
result["executed"] = True
result["prompt_id"] = exec_result.get("prompt_id")
result["trace_id"] = exec_result.get("trace_id")
if result.get("prompt_id"):
# NOTE: Persist executed_prompt_id so connector can deliver results
# after UI approvals. Do not remove without updating connector.
try:
self._service.record_execution(
approval_id,
prompt_id=result.get("prompt_id"),
trace_id=result.get("trace_id"),
actor=actor,
)
except Exception as record_error:
logger.error(
"Failed to record approval execution metadata: "
f"{record_error}"
)
logger.info(
f"Executed approved trigger: {approval_id} -> {result.get('prompt_id')}"
)
except Exception as exec_error:
logger.error(f"Failed to execute approved trigger: {exec_error}")
result["executed"] = False
result["execution_error"] = str(exec_error)
else:
result["executed"] = False
self._audit(
with request_tenant_scope(
request=request,
action="approvals.approve",
target=approval_id,
outcome="allow",
status_code=200,
details={"executed": result.get("executed", False), "actor": actor},
)
return web.json_response(result)
token_info=token_info,
allow_default_when_missing=True,
) as tenant:
# First approve the request
approval = self._service.approve(
approval_id, actor=actor, tenant_id=tenant.tenant_id
)
logger.info(f"Approved request: {approval_id}")
result = {
"tenant_id": tenant.tenant_id,
"approved": True,
"approval": approval.to_dict(),
}
# Execute if requested and submit_fn is available
if auto_execute and self._submit_fn:
try:
from .triggers import execute_approved_trigger
exec_result = await execute_approved_trigger(
approval_id=approval_id,
submit_fn=self._submit_fn,
)
result["executed"] = True
result["prompt_id"] = exec_result.get("prompt_id")
result["trace_id"] = exec_result.get("trace_id")
if result.get("prompt_id"):
# NOTE: Persist executed_prompt_id so connector can deliver results
# after UI approvals. Do not remove without updating connector.
try:
self._service.record_execution(
approval_id,
prompt_id=result.get("prompt_id"),
trace_id=result.get("trace_id"),
actor=actor,
tenant_id=tenant.tenant_id,
)
except Exception as record_error:
logger.error(
"Failed to record approval execution metadata: "
f"{record_error}"
)
logger.info(
f"Executed approved trigger: {approval_id} -> {result.get('prompt_id')}"
)
except Exception as exec_error:
logger.error(
f"Failed to execute approved trigger: {exec_error}"
)
result["executed"] = False
result["execution_error"] = str(exec_error)
else:
result["executed"] = False
self._audit(
request=request,
action="approvals.approve",
target=approval_id,
outcome="allow",
status_code=200,
details={
"tenant_id": tenant.tenant_id,
"executed": result.get("executed", False),
"actor": actor,
},
)
return web.json_response(result)
except TenantBoundaryError as exc:
return web.json_response(
{"error": exc.code, "message": str(exc)},
status=403,
)
except ValueError as e:
self._audit(
request=request,
@@ -337,25 +395,39 @@ class ApprovalHandlers:
except Exception:
pass
token_info = resolve_token_info(request)
try:
approval = self._service.reject(approval_id, actor=actor)
logger.info(f"Rejected request: {approval_id}")
self._audit(
with request_tenant_scope(
request=request,
action="approvals.reject",
target=approval_id,
outcome="allow",
status_code=200,
details={"actor": actor},
)
return web.json_response(
{
"rejected": True,
"approval": approval.to_dict(),
}
)
token_info=token_info,
allow_default_when_missing=True,
) as tenant:
approval = self._service.reject(
approval_id, actor=actor, tenant_id=tenant.tenant_id
)
logger.info(f"Rejected request: {approval_id}")
self._audit(
request=request,
action="approvals.reject",
target=approval_id,
outcome="allow",
status_code=200,
details={"tenant_id": tenant.tenant_id, "actor": actor},
)
return web.json_response(
{
"tenant_id": tenant.tenant_id,
"rejected": True,
"approval": approval.to_dict(),
}
)
except TenantBoundaryError as exc:
return web.json_response(
{"error": exc.code, "message": str(exc)},
status=403,
)
except ValueError as e:
self._audit(
request=request,
+521 -394
View File
File diff suppressed because it is too large Load Diff
+98 -31
View File
@@ -24,17 +24,23 @@ except ImportError: # pragma: no cover
web = _MockWeb() # type: ignore
if __package__ and "." in __package__:
from ..services.access_control import require_admin_token
from ..services.access_control import require_admin_token, resolve_token_info
from ..services.connector_installation_registry import (
get_connector_installation_registry,
)
from ..services.rate_limit import check_rate_limit
from ..services.tenant_context import TenantBoundaryError, request_tenant_scope
else: # pragma: no cover
from services.access_control import require_admin_token # type: ignore
from services.access_control import resolve_token_info # type: ignore
from services.connector_installation_registry import ( # type: ignore
get_connector_installation_registry,
)
from services.rate_limit import check_rate_limit # type: ignore
from services.tenant_context import ( # type: ignore
TenantBoundaryError,
request_tenant_scope,
)
if __package__ and "." in __package__:
from ..services.endpoint_manifest import (
@@ -82,16 +88,30 @@ async def connector_installations_list_handler(request):
platform = request.query.get("platform")
workspace_id = request.query.get("workspace_id")
status = request.query.get("status")
installations = registry.list_installations(
platform=platform, workspace_id=workspace_id, status=status
)
return web.json_response(
{
"ok": True,
"installations": [inst.to_public_dict() for inst in installations],
"diagnostics": registry.diagnostics(),
}
)
token_info = resolve_token_info(request)
try:
with request_tenant_scope(
request=request, token_info=token_info, allow_default_when_missing=True
) as tenant:
installations = registry.list_installations(
platform=platform,
tenant_id=tenant.tenant_id,
workspace_id=workspace_id,
status=status,
)
return web.json_response(
{
"ok": True,
"tenant_id": tenant.tenant_id,
"installations": [inst.to_public_dict() for inst in installations],
"diagnostics": registry.diagnostics(tenant_id=tenant.tenant_id),
}
)
except TenantBoundaryError as exc:
return web.json_response(
{"ok": False, "error": exc.code, "message": str(exc)},
status=403,
)
@endpoint_metadata(
@@ -107,12 +127,30 @@ async def connector_installation_get_handler(request):
return guard
installation_id = request.match_info.get("installation_id", "")
registry = get_connector_installation_registry()
installation = registry.get_installation(installation_id)
if installation is None:
return web.json_response({"ok": False, "error": "not_found"}, status=404)
return web.json_response(
{"ok": True, "installation": installation.to_public_dict()}
)
token_info = resolve_token_info(request)
try:
with request_tenant_scope(
request=request, token_info=token_info, allow_default_when_missing=True
) as tenant:
installation = registry.get_installation(
installation_id, tenant_id=tenant.tenant_id
)
if installation is None:
return web.json_response(
{"ok": False, "error": "not_found"}, status=404
)
return web.json_response(
{
"ok": True,
"tenant_id": tenant.tenant_id,
"installation": installation.to_public_dict(),
}
)
except TenantBoundaryError as exc:
return web.json_response(
{"ok": False, "error": exc.code, "message": str(exc)},
status=403,
)
@endpoint_metadata(
@@ -134,12 +172,28 @@ async def connector_installation_resolve_handler(request):
status=400,
)
registry = get_connector_installation_registry()
resolution = registry.resolve_installation(platform, workspace_id)
status_code = 200 if resolution.ok else 409
return web.json_response(
{"ok": resolution.ok, "resolution": resolution.to_public_dict()},
status=status_code,
)
token_info = resolve_token_info(request)
try:
with request_tenant_scope(
request=request, token_info=token_info, allow_default_when_missing=True
) as tenant:
resolution = registry.resolve_installation(
platform, workspace_id, tenant_id=tenant.tenant_id
)
status_code = 200 if resolution.ok else 409
return web.json_response(
{
"ok": resolution.ok,
"tenant_id": tenant.tenant_id,
"resolution": resolution.to_public_dict(),
},
status=status_code,
)
except TenantBoundaryError as exc:
return web.json_response(
{"ok": False, "error": exc.code, "message": str(exc)},
status=403,
)
@endpoint_metadata(
@@ -159,11 +213,24 @@ async def connector_installation_audit_handler(request):
limit = int(request.query.get("limit") or 100)
except Exception:
limit = 100
return web.json_response(
{
"ok": True,
"events": registry.get_audit_trail(
installation_id=installation_id, limit=limit
),
}
)
token_info = resolve_token_info(request)
try:
with request_tenant_scope(
request=request, token_info=token_info, allow_default_when_missing=True
) as tenant:
return web.json_response(
{
"ok": True,
"tenant_id": tenant.tenant_id,
"events": registry.get_audit_trail(
installation_id=installation_id,
tenant_id=tenant.tenant_id,
limit=limit,
),
}
)
except TenantBoundaryError as exc:
return web.json_response(
{"ok": False, "error": exc.code, "message": str(exc)},
status=403,
)
+121 -57
View File
@@ -11,7 +11,7 @@ from typing import Optional
from aiohttp import web
try:
from ..services.access_control import require_admin_token
from ..services.access_control import require_admin_token, resolve_token_info
from ..services.endpoint_manifest import (
AuthTier,
RiskTier,
@@ -19,9 +19,10 @@ try:
endpoint_metadata,
)
from ..services.presets import Preset, preset_store
from ..services.tenant_context import TenantBoundaryError, request_tenant_scope
except ImportError:
# Fallback for ComfyUI's non-package loader or ad-hoc imports.
from services.access_control import require_admin_token
from services.access_control import require_admin_token, resolve_token_info
from services.endpoint_manifest import (
AuthTier,
RiskTier,
@@ -29,6 +30,7 @@ except ImportError:
endpoint_metadata,
)
from services.presets import Preset, preset_store
from services.tenant_context import TenantBoundaryError, request_tenant_scope
logger = logging.getLogger("ComfyUI-OpenClaw.api.presets")
@@ -73,9 +75,24 @@ class PresetHandlers:
category = request.query.get("category")
tag = request.query.get("tag")
presets = preset_store.list_presets(category=category, tag=tag)
return web.json_response([p.to_dict() for p in presets])
token_info = resolve_token_info(request)
try:
with request_tenant_scope(
request=request,
token_info=token_info,
allow_default_when_missing=True,
) as tenant:
presets = preset_store.list_presets(
category=category,
tag=tag,
tenant_id=tenant.tenant_id,
)
return web.json_response([p.to_dict() for p in presets])
except TenantBoundaryError as exc:
return web.json_response(
{"error": exc.code, "message": str(exc)},
status=403,
)
@endpoint_metadata(
auth=AuthTier.PUBLIC, # Conditionally public
@@ -108,11 +125,23 @@ class PresetHandlers:
if not preset_id:
return web.json_response({"error": "Missing ID"}, status=400)
preset = preset_store.get_preset(preset_id)
if not preset:
return web.json_response({"error": "Not Found"}, status=404)
token_info = resolve_token_info(request)
try:
with request_tenant_scope(
request=request,
token_info=token_info,
allow_default_when_missing=True,
) as tenant:
preset = preset_store.get_preset(preset_id, tenant_id=tenant.tenant_id)
if not preset:
return web.json_response({"error": "Not Found"}, status=404)
return web.json_response(preset.to_dict())
return web.json_response(preset.to_dict())
except TenantBoundaryError as exc:
return web.json_response(
{"error": exc.code, "message": str(exc)},
status=403,
)
@endpoint_metadata(
auth=AuthTier.ADMIN,
@@ -138,28 +167,40 @@ class PresetHandlers:
if not name or not content:
return web.json_response({"error": "Name and Content required"}, status=400)
token_info = resolve_token_info(request)
try:
# Create object
preset = Preset.new(
name=data["name"],
content=data["content"],
category=data.get("category", "general"),
tags=data.get("tags", []),
)
# Milestone E: Schema Validation
try:
preset.validate_content()
except ValueError as e:
return web.json_response(
{"error": f"Validation Error: {str(e)}"}, status=400
with request_tenant_scope(
request=request,
token_info=token_info,
allow_default_when_missing=True,
) as tenant:
# Create object
preset = Preset.new(
name=data["name"],
content=data["content"],
category=data.get("category", "general"),
tags=data.get("tags", []),
)
preset.tenant_id = tenant.tenant_id
# Save
preset_store.save_preset(preset)
logger.info(f"Created preset {preset.id} ({preset.name})")
# Milestone E: Schema Validation
try:
preset.validate_content()
except ValueError as e:
return web.json_response(
{"error": f"Validation Error: {str(e)}"}, status=400
)
return web.json_response(preset.to_dict(), status=201)
# Save
preset_store.save_preset(preset)
logger.info(f"Created preset {preset.id} ({preset.name})")
return web.json_response(preset.to_dict(), status=201)
except TenantBoundaryError as exc:
return web.json_response(
{"error": exc.code, "message": str(exc)},
status=403,
)
except Exception as e:
logger.error(f"Failed to create preset: {e}")
return web.json_response({"error": str(e)}, status=500)
@@ -182,38 +223,50 @@ class PresetHandlers:
if not preset_id:
return web.json_response({"error": "Missing ID"}, status=400)
preset = preset_store.get_preset(preset_id)
if not preset:
return web.json_response({"error": "Not Found"}, status=404)
token_info = resolve_token_info(request)
try:
data = await request.json()
except Exception:
return web.json_response({"error": "Invalid JSON"}, status=400)
with request_tenant_scope(
request=request,
token_info=token_info,
allow_default_when_missing=True,
) as tenant:
preset = preset_store.get_preset(preset_id, tenant_id=tenant.tenant_id)
if not preset:
return web.json_response({"error": "Not Found"}, status=404)
# Update fields
if "name" in data:
preset.name = data["name"]
if "content" in data:
preset.content = data["content"]
if "category" in data:
preset.category = data["category"]
if "tags" in data:
preset.tags = data["tags"]
try:
data = await request.json()
except Exception:
return web.json_response({"error": "Invalid JSON"}, status=400)
# Milestone E: Schema Validation
try:
preset.validate_content()
except ValueError as e:
# Update fields
if "name" in data:
preset.name = data["name"]
if "content" in data:
preset.content = data["content"]
if "category" in data:
preset.category = data["category"]
if "tags" in data:
preset.tags = data["tags"]
# Milestone E: Schema Validation
try:
preset.validate_content()
except ValueError as e:
return web.json_response(
{"error": f"Validation Error: {str(e)}"}, status=400
)
preset.updated_at = time.time()
preset_store.save_preset(preset)
return web.json_response(preset.to_dict())
except TenantBoundaryError as exc:
return web.json_response(
{"error": f"Validation Error: {str(e)}"}, status=400
{"error": exc.code, "message": str(exc)},
status=403,
)
preset.updated_at = time.time()
preset_store.save_preset(preset)
return web.json_response(preset.to_dict())
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.HIGH,
@@ -232,10 +285,21 @@ class PresetHandlers:
if not preset_id:
return web.json_response({"error": "Missing ID"}, status=400)
if preset_store.delete_preset(preset_id):
return web.json_response({"ok": True})
else:
return web.json_response({"error": "Not Found or Failed"}, status=404)
token_info = resolve_token_info(request)
try:
with request_tenant_scope(
request=request,
token_info=token_info,
allow_default_when_missing=True,
) as tenant:
if preset_store.delete_preset(preset_id, tenant_id=tenant.tenant_id):
return web.json_response({"ok": True})
return web.json_response({"error": "Not Found or Failed"}, status=404)
except TenantBoundaryError as exc:
return web.json_response(
{"error": exc.code, "message": str(exc)},
status=403,
)
def register_preset_routes(app: web.Application):
+47 -27
View File
@@ -18,13 +18,24 @@ except ImportError: # pragma: no cover (optional for unit tests)
# - ComfyUI runtime: package-relative imports only (prevents collisions with other custom nodes).
# - Unit tests: allow top-level fallbacks.
if __package__ and "." in __package__:
from ..services.access_control import require_observability_access
from ..services.access_control import (
require_observability_access,
resolve_token_info,
)
from ..services.rate_limit import check_rate_limit
from ..services.templates import get_template_service
from ..services.tenant_context import TenantBoundaryError, request_tenant_scope
else: # pragma: no cover (test-only import mode)
from services.access_control import require_observability_access # type: ignore
from services.access_control import ( # type: ignore
require_observability_access,
resolve_token_info,
)
from services.rate_limit import check_rate_limit # type: ignore
from services.templates import get_template_service # type: ignore
from services.tenant_context import ( # type: ignore
TenantBoundaryError,
request_tenant_scope,
)
# R98: Endpoint Metadata
if __package__ and "." in __package__:
@@ -105,33 +116,42 @@ async def templates_list_handler(request: web.Request) -> web.Response:
try:
svc = get_template_service()
items = []
# Prefer runtime discovery (file-based templates) so operators don't need
# to maintain a separate allowlist file.
for template_id in svc.get_debug_info().get("discovered_template_ids", []): # type: ignore[call-arg]
cfg = svc.get_template_config(template_id) # type: ignore[arg-type]
if cfg is None:
continue
items.append(
{
"id": template_id,
"allowed_inputs": list(cfg.allowed_inputs or []),
"defaults": dict(cfg.defaults or {}),
}
)
items.sort(key=lambda x: x["id"])
resp: dict = {"ok": True, "templates": items, "count": len(items)}
token_info = resolve_token_info(request)
with request_tenant_scope(
request=request, token_info=token_info, allow_default_when_missing=True
):
items = []
# Prefer runtime discovery (file-based templates) so operators don't need
# to maintain a separate allowlist file.
for template_id in svc.get_debug_info().get("discovered_template_ids", []): # type: ignore[call-arg]
cfg = svc.get_template_config(template_id) # type: ignore[arg-type]
if cfg is None:
continue
items.append(
{
"id": template_id,
"allowed_inputs": list(cfg.allowed_inputs or []),
"defaults": dict(cfg.defaults or {}),
}
)
items.sort(key=lambda x: x["id"])
resp: dict = {"ok": True, "templates": items, "count": len(items)}
# Optional diagnostics. This reveals absolute paths, so keep it opt-in.
debug = request.query.get("debug", "").strip() in ("1", "true", "yes")
if debug:
try:
resp["debug"] = svc.get_debug_info() # type: ignore[attr-defined]
except Exception:
# If TemplateService interface changes, don't break the endpoint.
resp["debug"] = {"error": "debug_info_unavailable"}
# Optional diagnostics. This reveals absolute paths, so keep it opt-in.
debug = request.query.get("debug", "").strip() in ("1", "true", "yes")
if debug:
try:
resp["debug"] = svc.get_debug_info() # type: ignore[attr-defined]
except Exception:
# If TemplateService interface changes, don't break the endpoint.
resp["debug"] = {"error": "debug_info_unavailable"}
return web.json_response(resp)
return web.json_response(resp)
except TenantBoundaryError as e:
return web.json_response(
{"ok": False, "error": e.code, "message": str(e)},
status=403,
)
except Exception as e:
logger.exception("Failed to list templates")
return web.json_response({"ok": False, "error": str(e)}, status=500)
+63 -11
View File
@@ -9,6 +9,7 @@ import ipaddress
import logging
import os
import uuid
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Set, Tuple
@@ -18,6 +19,12 @@ except ImportError:
web = None
from .request_ip import get_client_ip
from .tenant_context import (
DEFAULT_TENANT_ID,
extract_tenant_from_headers,
is_multi_tenant_enabled,
normalize_tenant_id,
)
# S46: Scoped RBAC & Tiered Access
try:
@@ -97,6 +104,7 @@ class TokenInfo:
scopes: Set[str] = field(default_factory=set)
created_at: float = 0.0
expires_at: Optional[float] = None
tenant_id: str = DEFAULT_TENANT_ID
def has_scope(self, required: str) -> bool:
"""Check if token has scope, supporting wildcards."""
@@ -123,12 +131,17 @@ class TokenRegistry:
@classmethod
def issue(
cls, role: "AuthTier", scopes: List[str], ttl_seconds: int = 0
cls,
role: "AuthTier",
scopes: List[str],
ttl_seconds: int = 0,
tenant_id: str = DEFAULT_TENANT_ID,
) -> Tuple[str, TokenInfo]:
"""Issue a new token."""
secret = f"oc_{role.value}_{uuid.uuid4().hex}"
now = datetime.datetime.now().timestamp()
expires = (now + ttl_seconds) if ttl_seconds > 0 else None
normalized_tenant = normalize_tenant_id(tenant_id)
info = TokenInfo(
token_id=f"kid-{uuid.uuid4().hex[:8]}",
@@ -136,6 +149,7 @@ class TokenRegistry:
scopes=set(scopes),
created_at=now,
expires_at=expires,
tenant_id=normalized_tenant,
)
cls._tokens[secret] = info
return secret, info
@@ -153,18 +167,42 @@ class TokenRegistry:
return cls._tokens.get(secret)
def _header_token_value(headers: Mapping[str, str], key: str) -> str:
value = headers.get(key)
if value is None:
return ""
return str(value).strip()
def _resolve_header_tenant(request) -> str:
if not is_multi_tenant_enabled():
return DEFAULT_TENANT_ID
headers = getattr(request, "headers", None)
if not isinstance(headers, Mapping):
return DEFAULT_TENANT_ID
try:
tenant = extract_tenant_from_headers(headers)
except Exception:
return DEFAULT_TENANT_ID
return tenant or DEFAULT_TENANT_ID
def resolve_token_info(request) -> Optional[TokenInfo]:
"""
Resolve the request's authentication token into a TokenInfo object.
1. Check TokenRegistry (Dynamic)
2. Check Environment Variables (Static)
"""
headers = getattr(request, "headers", None)
if not isinstance(headers, Mapping):
headers = {}
# Extract token from headers
client_token = ""
if request.headers.get("X-OpenClaw-Admin-Token"):
client_token = request.headers.get("X-OpenClaw-Admin-Token")
elif request.headers.get("X-Moltbot-Admin-Token"):
client_token = request.headers.get("X-Moltbot-Admin-Token")
if _header_token_value(headers, "X-OpenClaw-Admin-Token"):
client_token = _header_token_value(headers, "X-OpenClaw-Admin-Token")
elif _header_token_value(headers, "X-Moltbot-Admin-Token"):
client_token = _header_token_value(headers, "X-Moltbot-Admin-Token")
try:
from .metrics import metrics
@@ -175,10 +213,10 @@ def resolve_token_info(request) -> Optional[TokenInfo]:
logger.warning(
"DEPRECATION WARNING: Legacy header X-Moltbot-Admin-Token used. Please migrate to X-OpenClaw-Admin-Token."
)
elif request.headers.get("X-OpenClaw-Obs-Token"):
client_token = request.headers.get("X-OpenClaw-Obs-Token")
elif request.headers.get("X-Moltbot-Obs-Token"):
client_token = request.headers.get("X-Moltbot-Obs-Token")
elif _header_token_value(headers, "X-OpenClaw-Obs-Token"):
client_token = _header_token_value(headers, "X-OpenClaw-Obs-Token")
elif _header_token_value(headers, "X-Moltbot-Obs-Token"):
client_token = _header_token_value(headers, "X-Moltbot-Obs-Token")
try:
from .metrics import metrics
@@ -190,6 +228,8 @@ def resolve_token_info(request) -> Optional[TokenInfo]:
"DEPRECATION WARNING: Legacy header X-Moltbot-Obs-Token used. Please migrate to X-OpenClaw-Obs-Token."
)
request_tenant = _resolve_header_tenant(request)
# 1. Registry Check
if client_token:
info = TokenRegistry.lookup(client_token)
@@ -206,7 +246,12 @@ def resolve_token_info(request) -> Optional[TokenInfo]:
if admin_token and client_token:
if hmac.compare_digest(client_token, admin_token):
return TokenInfo(token_id="env-admin", role=AuthTier.ADMIN, scopes={"*"})
return TokenInfo(
token_id="env-admin",
role=AuthTier.ADMIN,
scopes={"*"},
tenant_id=request_tenant,
)
# Observability
obs_token = (
@@ -223,6 +268,7 @@ def resolve_token_info(request) -> Optional[TokenInfo]:
token_id="env-obs",
role=AuthTier.OBSERVABILITY,
scopes={"read:*"}, # S46: Wildcard for Obs
tenant_id=request_tenant,
)
# 3. Loopback
@@ -230,12 +276,18 @@ def resolve_token_info(request) -> Optional[TokenInfo]:
if is_loopback(remote):
is_admin_configured = bool(admin_token)
if not is_admin_configured:
return TokenInfo(token_id="local-admin", role=AuthTier.ADMIN, scopes={"*"})
return TokenInfo(
token_id="local-admin",
role=AuthTier.ADMIN,
scopes={"*"},
tenant_id=request_tenant,
)
else:
return TokenInfo(
token_id="local-internal",
role=AuthTier.INTERNAL,
scopes={"internal:call"},
tenant_id=request_tenant,
)
return None
+16
View File
@@ -10,6 +10,17 @@ from datetime import datetime, timezone
from enum import Enum
from typing import Any, Dict, Optional
try:
from ..tenant_context import DEFAULT_TENANT_ID, normalize_tenant_id
except Exception: # pragma: no cover
DEFAULT_TENANT_ID = "default"
def normalize_tenant_id(value, *, field_name="tenant_id"): # type: ignore
text = str(value or "").strip().lower()
if not text:
raise ValueError(f"{field_name} must be non-empty")
return text
class ApprovalStatus(str, Enum):
"""Status of an approval request."""
@@ -57,6 +68,7 @@ class ApprovalRequest:
inputs: Dict[str, Any] = field(default_factory=dict)
source: ApprovalSource = ApprovalSource.TRIGGER
trace_id: Optional[str] = None
tenant_id: str = DEFAULT_TENANT_ID
status: ApprovalStatus = ApprovalStatus.PENDING
@@ -101,6 +113,8 @@ class ApprovalRequest:
if isinstance(self.status, str):
self.status = ApprovalStatus(self.status)
self.tenant_id = normalize_tenant_id(self.tenant_id, field_name="tenant_id")
@staticmethod
def generate_id() -> str:
"""Generate a new approval ID."""
@@ -167,6 +181,7 @@ class ApprovalRequest:
else self.source
),
"trace_id": self.trace_id,
"tenant_id": self.tenant_id,
"status": (
self.status.value
if isinstance(self.status, ApprovalStatus)
@@ -191,6 +206,7 @@ class ApprovalRequest:
inputs=data.get("inputs", {}),
source=ApprovalSource(data.get("source", "trigger")),
trace_id=data.get("trace_id"),
tenant_id=data.get("tenant_id", DEFAULT_TENANT_ID),
status=ApprovalStatus(data.get("status", "pending")),
requested_at=data.get(
"requested_at", datetime.now(timezone.utc).isoformat()
+36 -12
View File
@@ -8,6 +8,11 @@ import os
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
from ..tenant_context import (
DEFAULT_TENANT_ID,
get_current_tenant_id,
normalize_tenant_id,
)
from ..trace import generate_trace_id
from .models import ApprovalRequest, ApprovalSource, ApprovalStatus
from .storage import get_approval_store
@@ -38,6 +43,7 @@ class ApprovalService:
inputs: Dict[str, Any],
source: ApprovalSource = ApprovalSource.TRIGGER,
trace_id: Optional[str] = None,
tenant_id: Optional[str] = None,
requested_by: Optional[str] = None,
delivery: Optional[Dict[str, Any]] = None,
ttl_sec: Optional[int] = None,
@@ -65,6 +71,9 @@ class ApprovalService:
# Generate IDs
approval_id = ApprovalRequest.generate_id()
trace_id = trace_id or generate_trace_id()
resolved_tenant = normalize_tenant_id(
tenant_id or get_current_tenant_id() or DEFAULT_TENANT_ID
)
# Calculate expiration
ttl = ttl_sec if ttl_sec is not None else DEFAULT_TTL_SEC
@@ -77,6 +86,7 @@ class ApprovalService:
inputs=inputs,
source=source,
trace_id=trace_id,
tenant_id=resolved_tenant,
status=ApprovalStatus.PENDING,
requested_by=requested_by,
expires_at=expires_at,
@@ -89,20 +99,30 @@ class ApprovalService:
raise ValueError(f"Failed to create approval request: {approval_id}")
logger.info(
f"Created approval request: {approval_id} (template={template_id}, source={source.value})"
"Created approval request: %s (template=%s, source=%s, tenant=%s)",
approval_id,
template_id,
source.value,
resolved_tenant,
)
return request
def get(self, approval_id: str) -> Optional[ApprovalRequest]:
def get(
self, approval_id: str, tenant_id: Optional[str] = None
) -> Optional[ApprovalRequest]:
"""Get an approval request by ID."""
return self._store.get(approval_id)
return self._store.get(approval_id, tenant_id=tenant_id)
def list_pending(self, limit: int = 100) -> List[ApprovalRequest]:
def list_pending(
self, limit: int = 100, tenant_id: Optional[str] = None
) -> List[ApprovalRequest]:
"""List pending approval requests."""
# First expire any due requests
self._store.expire_due()
pending = self._store.list_by_status(ApprovalStatus.PENDING)
pending = self._store.list_by_status(
ApprovalStatus.PENDING, tenant_id=tenant_id
)
# Sort by requested_at (oldest first)
pending.sort(key=lambda x: x.requested_at)
@@ -114,14 +134,15 @@ class ApprovalService:
status: Optional[ApprovalStatus] = None,
limit: int = 100,
offset: int = 0,
tenant_id: Optional[str] = None,
) -> List[ApprovalRequest]:
"""List approval requests with optional status filter."""
self._store.expire_due()
if status:
approvals = self._store.list_by_status(status)
approvals = self._store.list_by_status(status, tenant_id=tenant_id)
else:
approvals = self._store.list_all()
approvals = self._store.list_all(tenant_id=tenant_id)
# Sort by requested_at (newest first for history)
approvals.sort(key=lambda x: x.requested_at, reverse=True)
@@ -132,6 +153,7 @@ class ApprovalService:
self,
approval_id: str,
actor: Optional[str] = None,
tenant_id: Optional[str] = None,
) -> ApprovalRequest:
"""
Approve a pending request.
@@ -146,7 +168,7 @@ class ApprovalService:
Raises:
ValueError: If request not found or not pending.
"""
request = self._store.get(approval_id)
request = self._store.get(approval_id, tenant_id=tenant_id)
if not request:
raise ValueError(f"Approval request not found: {approval_id}")
@@ -170,6 +192,7 @@ class ApprovalService:
self,
approval_id: str,
actor: Optional[str] = None,
tenant_id: Optional[str] = None,
) -> ApprovalRequest:
"""
Reject a pending request.
@@ -184,7 +207,7 @@ class ApprovalService:
Raises:
ValueError: If request not found or not pending.
"""
request = self._store.get(approval_id)
request = self._store.get(approval_id, tenant_id=tenant_id)
if not request:
raise ValueError(f"Approval request not found: {approval_id}")
@@ -204,6 +227,7 @@ class ApprovalService:
prompt_id: str,
trace_id: Optional[str] = None,
actor: Optional[str] = None,
tenant_id: Optional[str] = None,
) -> ApprovalRequest:
"""
Record execution metadata after an approval is executed.
@@ -211,7 +235,7 @@ class ApprovalService:
NOTE: The chat connector relies on executed_prompt_id to deliver images
when approvals are done in the UI. Do not remove without updating connector.
"""
request = self._store.get(approval_id)
request = self._store.get(approval_id, tenant_id=tenant_id)
if not request:
raise ValueError(f"Approval request not found: {approval_id}")
@@ -233,10 +257,10 @@ class ApprovalService:
)
return request
def count_pending(self) -> int:
def count_pending(self, tenant_id: Optional[str] = None) -> int:
"""Count pending approval requests."""
self._store.expire_due()
return self._store.count_pending()
return self._store.count_pending(tenant_id=tenant_id)
# Singleton instance
+39 -7
View File
@@ -11,6 +11,11 @@ from datetime import datetime, timezone
from typing import Dict, List, Optional
from ..state_dir import get_state_dir
from ..tenant_context import (
DEFAULT_TENANT_ID,
is_multi_tenant_enabled,
normalize_tenant_id,
)
from .models import ApprovalRequest, ApprovalStatus
logger = logging.getLogger("ComfyUI-OpenClaw.services.approvals")
@@ -129,11 +134,29 @@ class ApprovalStore:
self._approvals = load_approvals()
self._loaded = True
def get(self, approval_id: str) -> Optional[ApprovalRequest]:
def _match_tenant(
self, approval: ApprovalRequest, tenant_id: Optional[str]
) -> bool:
if not is_multi_tenant_enabled() or tenant_id is None:
return True
try:
expected = normalize_tenant_id(tenant_id)
except Exception:
expected = DEFAULT_TENANT_ID
return approval.tenant_id == expected
def get(
self, approval_id: str, tenant_id: Optional[str] = None
) -> Optional[ApprovalRequest]:
"""Get an approval by ID."""
with self._lock:
self._ensure_loaded()
return self._approvals.get(approval_id)
approval = self._approvals.get(approval_id)
if approval is None:
return None
if not self._match_tenant(approval, tenant_id):
return None
return approval
def add(self, approval: ApprovalRequest) -> bool:
"""Add a new approval."""
@@ -179,19 +202,27 @@ class ApprovalStore:
del self._approvals[approval_id]
return save_approvals(self._approvals)
def list_all(self) -> List[ApprovalRequest]:
def list_all(self, tenant_id: Optional[str] = None) -> List[ApprovalRequest]:
"""List all approvals."""
with self._lock:
self._ensure_loaded()
return list(self._approvals.values())
return [
a for a in self._approvals.values() if self._match_tenant(a, tenant_id)
]
def list_by_status(self, status: ApprovalStatus) -> List[ApprovalRequest]:
def list_by_status(
self, status: ApprovalStatus, tenant_id: Optional[str] = None
) -> List[ApprovalRequest]:
"""List approvals by status."""
with self._lock:
self._ensure_loaded()
return [a for a in self._approvals.values() if a.status == status]
return [
a
for a in self._approvals.values()
if a.status == status and self._match_tenant(a, tenant_id)
]
def count_pending(self) -> int:
def count_pending(self, tenant_id: Optional[str] = None) -> int:
"""Count pending approvals."""
with self._lock:
self._ensure_loaded()
@@ -199,6 +230,7 @@ class ApprovalStore:
1
for a in self._approvals.values()
if a.status == ApprovalStatus.PENDING
and self._match_tenant(a, tenant_id)
)
def expire_due(self) -> int:
+15 -1
View File
@@ -28,6 +28,14 @@ except ImportError:
get_connector_installation_registry,
)
try:
from .tenant_context import DEFAULT_TENANT_ID, get_current_tenant_id
except ImportError:
from services.tenant_context import ( # type: ignore
DEFAULT_TENANT_ID,
get_current_tenant_id,
)
logger = logging.getLogger("ComfyUI-OpenClaw.services.connector_callback_contract")
DEFAULT_CALLBACK_TIMESTAMP_DRIFT_SEC = 300
@@ -48,6 +56,7 @@ class CallbackDecisionCode(str, Enum):
REJECT_AMBIGUOUS_INSTALLATION = "cb_reject_ambiguous_installation"
REJECT_INACTIVE_INSTALLATION = "cb_reject_inactive_installation"
REJECT_STALE_TOKEN_REF = "cb_reject_stale_token_ref"
REJECT_TENANT_MISMATCH = "cb_reject_tenant_mismatch"
REJECT_POLICY_DENIED = "cb_reject_policy_denied"
REJECT_INVALID_ENVELOPE = "cb_reject_invalid_envelope"
@@ -57,6 +66,7 @@ class CallbackActorContext:
is_admin: bool = False
is_trusted: bool = False
user_id: str = ""
tenant_id: str = DEFAULT_TENANT_ID
@dataclass
@@ -189,6 +199,8 @@ class ConnectorCallbackContract:
code = CallbackDecisionCode.REJECT_INACTIVE_INSTALLATION.value
elif reason.startswith("stale_token_ref"):
code = CallbackDecisionCode.REJECT_STALE_TOKEN_REF.value
elif reason == "tenant_mismatch":
code = CallbackDecisionCode.REJECT_TENANT_MISMATCH.value
else:
code = CallbackDecisionCode.REJECT_INVALID_ENVELOPE.value
return CallbackDecision(ok=False, decision_code=code, message=reason)
@@ -302,7 +314,9 @@ class ConnectorCallbackContract:
return decision
resolution = self._installation_registry.resolve_installation(
platform, envelope.workspace_id
platform,
envelope.workspace_id,
tenant_id=(actor.tenant_id or get_current_tenant_id()),
)
if not resolution.ok or resolution.installation is None:
decision = self._map_installation_reject(resolution)
+78 -11
View File
@@ -14,10 +14,22 @@ try:
from .audit import emit_audit_event
from .secret_store import SecretStore, get_secret_store
from .state_dir import get_state_dir
from .tenant_context import (
DEFAULT_TENANT_ID,
get_current_tenant_id,
is_multi_tenant_enabled,
normalize_tenant_id,
)
except ImportError:
from services.audit import emit_audit_event # type: ignore
from services.secret_store import SecretStore, get_secret_store # type: ignore
from services.state_dir import get_state_dir # type: ignore
from services.tenant_context import ( # type: ignore
DEFAULT_TENANT_ID,
get_current_tenant_id,
is_multi_tenant_enabled,
normalize_tenant_id,
)
logger = logging.getLogger("ComfyUI-OpenClaw.services.connector_installation_registry")
@@ -44,6 +56,7 @@ class ConnectorInstallation:
platform: str
workspace_id: str
installation_id: str
tenant_id: str = DEFAULT_TENANT_ID
token_refs: Dict[str, str] = field(default_factory=dict)
status: str = InstallationStatus.CREATED.value
updated_at: float = field(default_factory=time.time)
@@ -54,6 +67,7 @@ class ConnectorInstallation:
def to_public_dict(self) -> Dict[str, Any]:
return {
"platform": self.platform,
"tenant_id": self.tenant_id,
"workspace_id": self.workspace_id,
"installation_id": self.installation_id,
"token_refs": dict(self.token_refs),
@@ -71,6 +85,7 @@ class InstallationAuditEvent:
action: str
installation_id: str
platform: str
tenant_id: str
workspace_id: str
status: str
details: Dict[str, Any] = field(default_factory=dict)
@@ -81,6 +96,7 @@ class InstallationAuditEvent:
"action": self.action,
"installation_id": self.installation_id,
"platform": self.platform,
"tenant_id": self.tenant_id,
"workspace_id": self.workspace_id,
"status": self.status,
"details": dict(self.details),
@@ -132,14 +148,14 @@ class ConnectorInstallationRegistry:
return self._normalize_identifier(platform, "platform").lower()
def _store_token_refs(
self, installation_id: str, token_values: Dict[str, str]
self, installation_id: str, token_values: Dict[str, str], tenant_id: str
) -> Dict[str, str]:
refs: Dict[str, str] = {}
for key, value in (token_values or {}).items():
name = self._normalize_identifier(key, "token_name")
secret_value = self._normalize_identifier(value, f"token:{name}")
ref = f"connector_installation:{installation_id}:{name}"
self._secret_store.set_secret(ref, secret_value)
self._secret_store.set_secret(ref, secret_value, tenant_id=tenant_id)
refs[name] = ref
return refs
@@ -154,6 +170,7 @@ class ConnectorInstallationRegistry:
action=action,
installation_id=installation.installation_id,
platform=installation.platform,
tenant_id=installation.tenant_id,
workspace_id=installation.workspace_id,
status=installation.status,
details=details,
@@ -168,6 +185,7 @@ class ConnectorInstallationRegistry:
status_code=200,
details={
"platform": installation.platform,
"tenant_id": installation.tenant_id,
"workspace_id": installation.workspace_id,
"status": installation.status,
**details,
@@ -185,6 +203,7 @@ class ConnectorInstallationRegistry:
for item in installations:
inst = ConnectorInstallation(
platform=item.get("platform", ""),
tenant_id=item.get("tenant_id", DEFAULT_TENANT_ID),
workspace_id=item.get("workspace_id", ""),
installation_id=item.get("installation_id", ""),
token_refs=dict(item.get("token_refs", {}) or {}),
@@ -202,6 +221,7 @@ class ConnectorInstallationRegistry:
action=item.get("action", "unknown"),
installation_id=item.get("installation_id", ""),
platform=item.get("platform", ""),
tenant_id=item.get("tenant_id", DEFAULT_TENANT_ID),
workspace_id=item.get("workspace_id", ""),
status=item.get("status", ""),
details=dict(item.get("details", {}) or {}),
@@ -228,6 +248,7 @@ class ConnectorInstallationRegistry:
self,
*,
platform: str,
tenant_id: str = DEFAULT_TENANT_ID,
workspace_id: str,
installation_id: str,
token_values: Optional[Dict[str, str]] = None,
@@ -238,6 +259,7 @@ class ConnectorInstallationRegistry:
) -> ConnectorInstallation:
with self._lock:
normalized_platform = self._normalize_platform(platform)
normalized_tenant = normalize_tenant_id(tenant_id)
normalized_workspace = self._normalize_identifier(
workspace_id, "workspace_id"
)
@@ -248,7 +270,9 @@ class ConnectorInstallationRegistry:
refs = dict(token_refs or {})
if token_values:
refs.update(
self._store_token_refs(normalized_installation, token_values)
self._store_token_refs(
normalized_installation, token_values, normalized_tenant
)
)
existing = self._installations.get(normalized_installation)
if existing is None:
@@ -259,6 +283,7 @@ class ConnectorInstallationRegistry:
refs = dict(existing.token_refs)
inst = ConnectorInstallation(
platform=normalized_platform,
tenant_id=normalized_tenant,
workspace_id=normalized_workspace,
installation_id=normalized_installation,
token_refs=refs,
@@ -273,15 +298,25 @@ class ConnectorInstallationRegistry:
self._save()
return inst
def get_installation(self, installation_id: str) -> Optional[ConnectorInstallation]:
def get_installation(
self, installation_id: str, tenant_id: Optional[str] = None
) -> Optional[ConnectorInstallation]:
with self._lock:
inst = self._installations.get(str(installation_id).strip())
if (
inst is not None
and is_multi_tenant_enabled()
and tenant_id is not None
and inst.tenant_id != normalize_tenant_id(tenant_id)
):
return None
return None if inst is None else ConnectorInstallation(**asdict(inst))
def list_installations(
self,
*,
platform: Optional[str] = None,
tenant_id: Optional[str] = None,
workspace_id: Optional[str] = None,
status: Optional[str] = None,
) -> List[ConnectorInstallation]:
@@ -291,6 +326,9 @@ class ConnectorInstallationRegistry:
items = [
i for i in items if i.platform == self._normalize_platform(platform)
]
if tenant_id:
normalized_tenant = normalize_tenant_id(tenant_id)
items = [i for i in items if i.tenant_id == normalized_tenant]
if workspace_id:
items = [
i for i in items if i.workspace_id == str(workspace_id).strip()
@@ -300,6 +338,7 @@ class ConnectorInstallationRegistry:
items.sort(
key=lambda inst: (
inst.platform,
inst.tenant_id,
inst.workspace_id,
inst.installation_id,
)
@@ -349,7 +388,11 @@ class ConnectorInstallationRegistry:
if inst is None:
raise ValueError(f"Installation not found: {installation_id}")
refs = dict(inst.token_refs)
refs.update(self._store_token_refs(inst.installation_id, token_values))
refs.update(
self._store_token_refs(
inst.installation_id, token_values, inst.tenant_id
)
)
inst.token_refs = refs
inst.status = InstallationStatus.ROTATING.value
inst.status_reason = reason
@@ -392,7 +435,7 @@ class ConnectorInstallationRegistry:
if inst is None:
raise ValueError(f"Installation not found: {installation_id}")
for ref in list(inst.token_refs.values()):
self._secret_store.clear_secret(ref)
self._secret_store.clear_secret(ref, tenant_id=inst.tenant_id)
inst.status = InstallationStatus.UNINSTALLED.value
inst.status_reason = reason
inst.updated_at = time.time()
@@ -402,19 +445,33 @@ class ConnectorInstallationRegistry:
return ConnectorInstallation(**asdict(inst))
def resolve_installation(
self, platform: str, workspace_id: str
self, platform: str, workspace_id: str, tenant_id: Optional[str] = None
) -> InstallationResolution:
with self._lock:
normalized_platform = self._normalize_platform(platform)
normalized_workspace = self._normalize_identifier(
workspace_id, "workspace_id"
)
normalized_tenant = normalize_tenant_id(
tenant_id or get_current_tenant_id() or DEFAULT_TENANT_ID
)
matches = [
inst
for inst in self._installations.values()
if inst.platform == normalized_platform
and inst.workspace_id == normalized_workspace
]
if is_multi_tenant_enabled():
tenant_matches = [
inst for inst in matches if inst.tenant_id == normalized_tenant
]
if not tenant_matches and matches:
return InstallationResolution(
ok=False,
reject_reason="tenant_mismatch",
audit_code="conn_install.resolve_tenant_mismatch",
)
matches = tenant_matches
eligible = [inst for inst in matches if inst.status in _RESOLVABLE_STATUSES]
if len(eligible) > 1:
return InstallationResolution(
@@ -436,7 +493,7 @@ class ConnectorInstallationRegistry:
)
inst = eligible[0]
for token_name, ref in inst.token_refs.items():
if not self._secret_store.get_secret(ref):
if not self._secret_store.get_secret(ref, tenant_id=inst.tenant_id):
return InstallationResolution(
ok=False,
reject_reason=f"stale_token_ref:{token_name}",
@@ -452,6 +509,7 @@ class ConnectorInstallationRegistry:
self,
*,
installation_id: Optional[str] = None,
tenant_id: Optional[str] = None,
limit: int = 100,
) -> List[Dict[str, Any]]:
with self._lock:
@@ -462,15 +520,24 @@ class ConnectorInstallationRegistry:
for event in items
if event.installation_id == str(installation_id).strip()
]
if tenant_id:
normalized_tenant = normalize_tenant_id(tenant_id)
items = [
event for event in items if event.tenant_id == normalized_tenant
]
return [event.to_dict() for event in items[-max(1, min(limit, 500)) :]]
def diagnostics(self) -> Dict[str, Any]:
def diagnostics(self, tenant_id: Optional[str] = None) -> Dict[str, Any]:
with self._lock:
counts: Dict[str, int] = {}
for inst in self._installations.values():
items = list(self._installations.values())
if tenant_id:
normalized_tenant = normalize_tenant_id(tenant_id)
items = [inst for inst in items if inst.tenant_id == normalized_tenant]
for inst in items:
counts[inst.status] = counts.get(inst.status, 0) + 1
return {
"installation_count": len(self._installations),
"installation_count": len(items),
"status_counts": counts,
"audit_events": len(self._audit_trail),
}
+80 -3
View File
@@ -20,6 +20,13 @@ from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Dict, Optional
from .tenant_context import (
DEFAULT_TENANT_ID,
get_current_tenant_id,
is_multi_tenant_enabled,
normalize_tenant_id,
)
logger = logging.getLogger("ComfyUI-OpenClaw.services.execution_budgets")
# Global concurrency budgets (tuneable via env vars)
@@ -28,6 +35,7 @@ DEFAULT_MAX_INFLIGHT_WEBHOOK = 1
DEFAULT_MAX_INFLIGHT_TRIGGER = 1
DEFAULT_MAX_INFLIGHT_SCHEDULER = 1
DEFAULT_MAX_INFLIGHT_BRIDGE = 1
DEFAULT_MAX_INFLIGHT_PER_TENANT = 1
# Render size budget (512KB default)
DEFAULT_MAX_RENDERED_WORKFLOW_BYTES = 512 * 1024 # 512KB
@@ -54,7 +62,8 @@ class BudgetConfig:
max_inflight_trigger: int
max_inflight_scheduler: int
max_inflight_bridge: int
max_rendered_workflow_bytes: int
max_inflight_per_tenant: int = DEFAULT_MAX_INFLIGHT_PER_TENANT
max_rendered_workflow_bytes: int = DEFAULT_MAX_RENDERED_WORKFLOW_BYTES
def load_budget_config() -> BudgetConfig:
@@ -75,6 +84,10 @@ def load_budget_config() -> BudgetConfig:
max_inflight_bridge=_get_env_int(
"OPENCLAW_MAX_INFLIGHT_SUBMITS_BRIDGE", DEFAULT_MAX_INFLIGHT_BRIDGE
),
max_inflight_per_tenant=_get_env_int(
"OPENCLAW_MAX_INFLIGHT_SUBMITS_PER_TENANT",
DEFAULT_MAX_INFLIGHT_PER_TENANT,
),
max_rendered_workflow_bytes=_get_env_int(
"OPENCLAW_MAX_RENDERED_WORKFLOW_BYTES", DEFAULT_MAX_RENDERED_WORKFLOW_BYTES
),
@@ -109,6 +122,9 @@ class ExecutionBudgetLimiter:
"scheduler": asyncio.Semaphore(self.config.max_inflight_scheduler),
"bridge": asyncio.Semaphore(self.config.max_inflight_bridge),
}
# S49: optional per-tenant semaphores (created lazily).
self._tenant_semaphores: Dict[str, asyncio.Semaphore] = {}
self._tenant_lock = asyncio.Lock()
# Tracking counters (for observability)
self._inflight_total = 0
@@ -119,9 +135,25 @@ class ExecutionBudgetLimiter:
"bridge": 0,
"unknown": 0,
}
self._inflight_by_tenant: Dict[str, int] = {}
async def _get_tenant_semaphore(self, tenant_id: str) -> asyncio.Semaphore:
async with self._tenant_lock:
existing = self._tenant_semaphores.get(tenant_id)
if existing is not None:
return existing
cap = max(1, int(self.config.max_inflight_per_tenant))
semaphore = asyncio.Semaphore(cap)
self._tenant_semaphores[tenant_id] = semaphore
return semaphore
@asynccontextmanager
async def acquire(self, source: str = "unknown", trace_id: Optional[str] = None):
async def acquire(
self,
source: str = "unknown",
trace_id: Optional[str] = None,
tenant_id: Optional[str] = None,
):
"""
Acquire concurrency slots for execution (best-effort non-blocking).
@@ -146,6 +178,9 @@ class ExecutionBudgetLimiter:
source = source.lower() if source else "unknown"
if source not in self._source_semaphores:
source = "unknown"
tenant = DEFAULT_TENANT_ID
if is_multi_tenant_enabled():
tenant = normalize_tenant_id(tenant_id or get_current_tenant_id())
# Check global budget (locked check + manual acquire)
if self._global_semaphore.locked():
@@ -195,7 +230,27 @@ class ExecutionBudgetLimiter:
retry_after=1,
)
# Acquire both semaphores manually (explicit control)
# S49: Optional per-tenant concurrency cap.
tenant_semaphore = None
if is_multi_tenant_enabled():
tenant_semaphore = await self._get_tenant_semaphore(tenant)
if tenant_semaphore.locked():
limit = max(1, int(self.config.max_inflight_per_tenant))
logger.warning(
"Tenant concurrency budget exhausted for tenant=%s (max=%s), denying %s submission (trace_id=%s)",
tenant,
limit,
source,
trace_id,
)
raise BudgetExceededError(
budget_type="tenant_concurrency",
limit=limit,
source=source,
retry_after=1,
)
# Acquire semaphores manually (explicit control)
try:
await self._global_semaphore.acquire()
except Exception:
@@ -205,12 +260,23 @@ class ExecutionBudgetLimiter:
global_acquired = True
try:
tenant_acquired = False
if tenant_semaphore is not None:
try:
await tenant_semaphore.acquire()
tenant_acquired = True
except Exception:
self._global_semaphore.release()
raise
if source_semaphore:
try:
await source_semaphore.acquire()
source_acquired = True
except Exception:
# Failed to acquire source, release global and re-raise
if tenant_acquired and tenant_semaphore is not None:
tenant_semaphore.release()
self._global_semaphore.release()
raise
else:
@@ -221,6 +287,9 @@ class ExecutionBudgetLimiter:
self._inflight_by_source[source] = (
self._inflight_by_source.get(source, 0) + 1
)
self._inflight_by_tenant[tenant] = (
self._inflight_by_tenant.get(tenant, 0) + 1
)
logger.debug(
f"Acquired budget for {source} (inflight: total={self._inflight_total}, "
@@ -233,9 +302,16 @@ class ExecutionBudgetLimiter:
# Release and update tracking (always runs)
self._inflight_total -= 1
self._inflight_by_source[source] -= 1
self._inflight_by_tenant[tenant] = max(
0, self._inflight_by_tenant.get(tenant, 0) - 1
)
if self._inflight_by_tenant[tenant] == 0:
self._inflight_by_tenant.pop(tenant, None)
if source_acquired and source_semaphore:
source_semaphore.release()
if tenant_acquired and tenant_semaphore is not None:
tenant_semaphore.release()
self._global_semaphore.release()
logger.debug(
@@ -252,6 +328,7 @@ class ExecutionBudgetLimiter:
return {
"total": self._inflight_total,
**self._inflight_by_source,
"tenant_count": len(self._inflight_by_tenant),
}
+19 -1
View File
@@ -8,6 +8,17 @@ import uuid
from dataclasses import asdict, dataclass, field
from typing import Any, Dict, List, Optional
try:
from ..tenant_context import DEFAULT_TENANT_ID, normalize_tenant_id
except Exception: # pragma: no cover
DEFAULT_TENANT_ID = "default"
def normalize_tenant_id(value, *, field_name="tenant_id"): # type: ignore
text = str(value or "").strip().lower()
if not text:
raise ValueError(f"{field_name} must be non-empty")
return text
@dataclass
class Preset:
@@ -21,6 +32,7 @@ class Preset:
category: str = "general" # e.g., "prompt", "parameters", "full"
tags: List[str] = field(default_factory=list)
content: Dict[str, Any] = field(default_factory=dict)
tenant_id: str = DEFAULT_TENANT_ID
created_at: float = field(default_factory=time.time)
updated_at: float = field(default_factory=time.time)
@@ -39,6 +51,7 @@ class Preset:
content=content,
category=category,
tags=tags or [],
tenant_id=DEFAULT_TENANT_ID,
)
def to_dict(self) -> Dict[str, Any]:
@@ -71,4 +84,9 @@ class Preset:
@staticmethod
def from_dict(data: Dict[str, Any]) -> "Preset":
return Preset(**data)
if "tenant_id" not in data:
data = dict(data)
data["tenant_id"] = DEFAULT_TENANT_ID
preset = Preset(**data)
preset.tenant_id = normalize_tenant_id(preset.tenant_id, field_name="tenant_id")
return preset
+45 -4
View File
@@ -9,6 +9,11 @@ from pathlib import Path
from typing import Dict, List, Optional
from ..paths import get_presets_dir
from ..tenant_context import (
DEFAULT_TENANT_ID,
is_multi_tenant_enabled,
normalize_tenant_id,
)
from .models import Preset
logger = logging.getLogger("ComfyUI-OpenClaw.services.presets")
@@ -25,8 +30,25 @@ class PresetStore:
def _get_path(self, preset_id: str) -> Path:
return self.storage_dir / f"{preset_id}.json"
def _resolve_tenant_id(self, tenant_id: Optional[str]) -> Optional[str]:
if not is_multi_tenant_enabled():
return None
try:
return normalize_tenant_id(tenant_id or DEFAULT_TENANT_ID)
except Exception:
return DEFAULT_TENANT_ID
def _is_visible_to_tenant(self, preset: Preset, tenant_id: Optional[str]) -> bool:
resolved = self._resolve_tenant_id(tenant_id)
if resolved is None:
return True
return preset.tenant_id == resolved
def list_presets(
self, category: Optional[str] = None, tag: Optional[str] = None
self,
category: Optional[str] = None,
tag: Optional[str] = None,
tenant_id: Optional[str] = None,
) -> List[Preset]:
"""List all presets, optionally filtered."""
presets = []
@@ -35,6 +57,8 @@ class PresetStore:
try:
p = self._load_file(file_path)
if p:
if not self._is_visible_to_tenant(p, tenant_id):
continue
if category and p.category != category:
continue
if tag and tag not in p.tags:
@@ -50,15 +74,29 @@ class PresetStore:
presets.sort(key=lambda x: x.updated_at, reverse=True)
return presets
def get_preset(self, preset_id: str) -> Optional[Preset]:
def get_preset(
self, preset_id: str, tenant_id: Optional[str] = None
) -> Optional[Preset]:
"""Get a specific preset."""
path = self._get_path(preset_id)
if not path.exists():
return None
return self._load_file(path)
preset = self._load_file(path)
if preset is None:
return None
if not self._is_visible_to_tenant(preset, tenant_id):
return None
return preset
def save_preset(self, preset: Preset) -> bool:
"""Save/Update a preset."""
try:
preset.tenant_id = normalize_tenant_id(
getattr(preset, "tenant_id", DEFAULT_TENANT_ID),
field_name="tenant_id",
)
except Exception:
preset.tenant_id = DEFAULT_TENANT_ID
path = self._get_path(preset.id)
try:
with open(path, "w", encoding="utf-8") as f:
@@ -69,11 +107,14 @@ class PresetStore:
logger.error(f"Failed to save preset {preset.id}: {e}")
return False
def delete_preset(self, preset_id: str) -> bool:
def delete_preset(self, preset_id: str, tenant_id: Optional[str] = None) -> bool:
"""Delete a preset."""
path = self._get_path(preset_id)
if not path.exists():
return False
preset = self._load_file(path)
if preset is not None and not self._is_visible_to_tenant(preset, tenant_id):
return False
try:
path.unlink()
logger.info(f"Deleted preset {preset_id}")
+12 -6
View File
@@ -20,7 +20,9 @@ GENERIC_KEY_NAMES = [
]
def get_api_key_for_provider(provider: str) -> Optional[str]:
def get_api_key_for_provider(
provider: str, tenant_id: Optional[str] = None
) -> Optional[str]:
"""
Get API key for a specific provider.
@@ -32,7 +34,7 @@ def get_api_key_for_provider(provider: str) -> Optional[str]:
Returns None if no key found (acceptable for local providers).
"""
key, _source = resolve_provider_secret(provider)
key, _source = resolve_provider_secret(provider, tenant_id=tenant_id)
return key
@@ -55,7 +57,7 @@ def mask_api_key(key: str) -> str:
return f"{key[:4]}...{key[-4:]}"
def get_all_configured_keys() -> dict:
def get_all_configured_keys(tenant_id: Optional[str] = None) -> dict:
"""
Get a summary of configured keys (masked).
Used for diagnostics, never returns actual key values.
@@ -70,7 +72,7 @@ def get_all_configured_keys() -> dict:
from ..secret_store import get_secret_store
store = get_secret_store()
store_status = store.get_status()
store_status = store.get_status(tenant_id=tenant_id)
except Exception as e:
logger.debug(f"S25: Failed to get secret store status (non-fatal): {e}")
@@ -106,7 +108,9 @@ def get_all_configured_keys() -> dict:
source = "server_store"
if key is None and source is None:
resolved, resolved_source = resolve_provider_secret(provider_id)
resolved, resolved_source = resolve_provider_secret(
provider_id, tenant_id=tenant_id
)
if resolved:
key = resolved
source = resolved_source
@@ -137,7 +141,9 @@ def get_all_configured_keys() -> dict:
if generic_key is None and "generic" in store_status:
generic_source = "server_store"
if generic_key is None and generic_source is None:
resolved, resolved_source = resolve_provider_secret("generic")
resolved, resolved_source = resolve_provider_secret(
"generic", tenant_id=tenant_id
)
if resolved:
generic_key = resolved
generic_source = resolved_source
+16 -1
View File
@@ -46,6 +46,11 @@ configure_logger_for_structured_output(logger)
import os
try:
from .tenant_context import get_current_tenant_id
except ImportError:
from services.tenant_context import get_current_tenant_id # type: ignore
# ComfyUI internal server URL fallback
COMFYUI_URL = (
os.environ.get("OPENCLAW_COMFYUI_URL")
@@ -60,6 +65,7 @@ async def submit_prompt(
extra_data: Optional[Dict[str, Any]] = None,
source: str = "unknown", # R33: Source tracking
trace_id: Optional[str] = None, # R33: Trace ID for logging
tenant_id: Optional[str] = None, # S49: tenant context for budget + audit metadata
) -> Dict[str, Any]:
"""
Submit a prompt workflow to ComfyUI with execution budgets (R33).
@@ -85,6 +91,7 @@ async def submit_prompt(
"source": source,
"trace_id": trace_id,
"has_extra_data": bool(extra_data),
"tenant_id": tenant_id or get_current_tenant_id(),
},
)
# NOTE: Must try relative import first. In ComfyUI runtime, `services` is not a top-level module.
@@ -108,6 +115,10 @@ async def submit_prompt(
if extra_data:
payload["extra_data"] = extra_data
# S49: keep tenant context in queue metadata for cross-service traceability.
openclaw_extra = payload.setdefault("extra_data", {}).setdefault("openclaw", {})
openclaw_extra.setdefault("tenant_id", tenant_id or get_current_tenant_id())
# NOTE: Debug-only full payload logging for troubleshooting mismatched outputs.
# Enable with OPENCLAW_DEBUG_PROMPT_PAYLOAD=1. This may include sensitive prompt content.
if os.environ.get("OPENCLAW_DEBUG_PROMPT_PAYLOAD", "").strip().lower() in (
@@ -132,7 +143,11 @@ async def submit_prompt(
# R33: Acquire concurrency budget
limiter = get_limiter()
async with limiter.acquire(source=source, trace_id=trace_id):
async with limiter.acquire(
source=source,
trace_id=trace_id,
tenant_id=tenant_id or get_current_tenant_id(),
):
# Use aiohttp to post to local ComfyUI instance
# We assume we are running INSIDE ComfyUI process, but for HTTP access we use localhost
# unless we can hook internal server entry point.
+116 -15
View File
@@ -12,6 +12,35 @@ from urllib.parse import urlparse
logger = logging.getLogger("ComfyUI-OpenClaw.services.runtime_config")
# S49: tenant context + namespace-aware config resolution.
try:
from .tenant_context import (
DEFAULT_TENANT_ID,
get_current_tenant_id,
is_multi_tenant_enabled,
normalize_tenant_id,
)
except ImportError:
try:
from services.tenant_context import ( # type: ignore
DEFAULT_TENANT_ID,
get_current_tenant_id,
is_multi_tenant_enabled,
normalize_tenant_id,
)
except ImportError:
DEFAULT_TENANT_ID = "default"
def get_current_tenant_id(): # type: ignore
return DEFAULT_TENANT_ID
def is_multi_tenant_enabled(): # type: ignore
return False
def normalize_tenant_id(value): # type: ignore
return str(value or DEFAULT_TENANT_ID).strip().lower() or DEFAULT_TENANT_ID
# R70: Settings schema registry (type coercion + unknown-key rejection)
try:
from .settings_schema import coerce_dict as _schema_coerce
@@ -389,6 +418,52 @@ def _get_constraint_range(key: str) -> Tuple[int, int]:
return min_val, max_val
def _resolve_active_tenant_id(tenant_id: Optional[str] = None) -> str:
if not is_multi_tenant_enabled():
return DEFAULT_TENANT_ID
if tenant_id is None:
tenant_id = get_current_tenant_id()
try:
return normalize_tenant_id(tenant_id)
except Exception:
return DEFAULT_TENANT_ID
def _allow_tenant_config_fallback() -> bool:
value = (
os.environ.get("OPENCLAW_MULTI_TENANT_ALLOW_CONFIG_FALLBACK")
or os.environ.get("MOLTBOT_MULTI_TENANT_ALLOW_CONFIG_FALLBACK")
or "0"
)
return str(value).strip().lower() in ("1", "true", "yes", "on")
def _runtime_override_section(tenant_id: Optional[str] = None) -> str:
resolved = _resolve_active_tenant_id(tenant_id)
if resolved == DEFAULT_TENANT_ID:
return "llm"
return f"llm::{resolved}"
def _tenant_llm_config_view(
config_blob: Dict[str, Any], tenant_id: str
) -> Dict[str, Any]:
llm_global = config_blob.get("llm", {})
if tenant_id == DEFAULT_TENANT_ID:
return llm_global if isinstance(llm_global, dict) else {}
tenants = config_blob.get("tenants", {})
tenant_cfg = {}
if isinstance(tenants, dict):
tenant_cfg = tenants.get(tenant_id, {})
tenant_llm = tenant_cfg.get("llm", {}) if isinstance(tenant_cfg, dict) else {}
if isinstance(tenant_llm, dict) and tenant_llm:
return tenant_llm
if _allow_tenant_config_fallback() and isinstance(llm_global, dict):
return llm_global
return {}
def _load_file_config() -> Dict[str, Any]:
"""Load config from file if exists."""
if os.path.exists(CONFIG_FILE):
@@ -584,12 +659,14 @@ def _normalize_llm_layer_value(key: str, value: Any, source: str) -> Any:
return value
def get_runtime_overrides() -> Dict[str, Any]:
def get_runtime_overrides(tenant_id: Optional[str] = None) -> Dict[str, Any]:
"""Get current in-memory runtime overrides for the LLM section."""
return _get_runtime_overrides("llm")
return _get_runtime_overrides(_runtime_override_section(tenant_id))
def set_runtime_overrides(updates: Dict[str, Any]) -> Tuple[bool, list]:
def set_runtime_overrides(
updates: Dict[str, Any], tenant_id: Optional[str] = None
) -> Tuple[bool, list]:
"""
Set in-memory runtime overrides for LLM config (non-persisted).
@@ -598,16 +675,20 @@ def set_runtime_overrides(updates: Dict[str, Any]) -> Tuple[bool, list]:
sanitized, errors = validate_config_update(updates)
if errors:
return False, errors
_set_runtime_overrides("llm", sanitized)
_set_runtime_overrides(_runtime_override_section(tenant_id), sanitized)
return True, []
def clear_runtime_overrides(keys: Optional[List[str]] = None) -> None:
def clear_runtime_overrides(
keys: Optional[List[str]] = None, tenant_id: Optional[str] = None
) -> None:
"""Clear all runtime overrides (or only selected keys) for LLM config."""
_clear_runtime_overrides("llm", keys=keys)
_clear_runtime_overrides(_runtime_override_section(tenant_id), keys=keys)
def get_effective_config() -> Tuple[Dict[str, Any], Dict[str, str]]:
def get_effective_config(
tenant_id: Optional[str] = None,
) -> Tuple[Dict[str, Any], Dict[str, str]]:
"""
Get effective LLM config with precedence:
ENV > runtime_override > persisted file > defaults.
@@ -615,8 +696,10 @@ def get_effective_config() -> Tuple[Dict[str, Any], Dict[str, str]]:
Returns:
Tuple of (effective_config, sources) where sources maps each key to its origin.
"""
file_config = _load_file_config().get("llm", {})
runtime_overrides = get_runtime_overrides()
active_tenant = _resolve_active_tenant_id(tenant_id)
file_blob = _load_file_config()
file_config = _tenant_llm_config_view(file_blob, active_tenant)
runtime_overrides = get_runtime_overrides(active_tenant)
ordered_keys = list(LLM_KEY_ORDER) + [
k for k in sorted(ALLOWED_LLM_KEYS) if k not in ENV_MAPPINGS
@@ -883,7 +966,9 @@ def _merge_config_value(base: Any, patch: Any, key: str = "") -> Any:
return patch
def update_config(updates: Dict[str, Any]) -> Tuple[bool, list]:
def update_config(
updates: Dict[str, Any], tenant_id: Optional[str] = None
) -> Tuple[bool, list]:
"""
Update LLM config, persisting to file.
@@ -898,16 +983,32 @@ def update_config(updates: Dict[str, Any]) -> Tuple[bool, list]:
if not sanitized:
return True, [] # Nothing to update
# R94: Non-destructive merge with existing file config
tenant_id = _resolve_active_tenant_id(tenant_id)
# R94/S49: Non-destructive merge with existing file config
file_config = _load_file_config()
if "llm" not in file_config:
file_config["llm"] = {}
if tenant_id == DEFAULT_TENANT_ID:
if "llm" not in file_config:
file_config["llm"] = {}
target = file_config["llm"]
else:
tenants = file_config.get("tenants")
if not isinstance(tenants, dict):
tenants = {}
file_config["tenants"] = tenants
tenant_cfg = tenants.get(tenant_id)
if not isinstance(tenant_cfg, dict):
tenant_cfg = {}
tenants[tenant_id] = tenant_cfg
if "llm" not in tenant_cfg or not isinstance(tenant_cfg.get("llm"), dict):
tenant_cfg["llm"] = {}
target = tenant_cfg["llm"]
for k, v in sanitized.items():
file_config["llm"][k] = _merge_config_value(file_config["llm"].get(k), v, key=k)
target[k] = _merge_config_value(target.get(k), v, key=k)
if _save_file_config(file_config):
logger.info(f"Updated config: {list(sanitized.keys())}")
logger.info("Updated config: %s (tenant=%s)", list(sanitized.keys()), tenant_id)
return True, []
else:
return False, ["Failed to save config file"]
+42 -13
View File
@@ -30,6 +30,21 @@ try:
except ImportError:
from services.providers.catalog import get_provider_info # type: ignore
try:
from .tenant_context import (
DEFAULT_TENANT_ID,
get_current_tenant_id,
is_multi_tenant_enabled,
normalize_tenant_id,
)
except ImportError:
from services.tenant_context import ( # type: ignore
DEFAULT_TENANT_ID,
get_current_tenant_id,
is_multi_tenant_enabled,
normalize_tenant_id,
)
logger = logging.getLogger("ComfyUI-OpenClaw.services.secret_providers")
_TRUTHY = {"1", "true", "yes", "on"}
@@ -57,7 +72,7 @@ def _env_value(
class SecretProvider(Protocol):
source: str
def get_secret(self, provider: str) -> Optional[str]: ...
def get_secret(self, provider: str, tenant_id: str) -> Optional[str]: ...
class EnvSecretProvider:
@@ -74,7 +89,7 @@ class EnvSecretProvider:
candidates.append(info.env_key_name)
return candidates
def get_secret(self, provider: str) -> Optional[str]:
def get_secret(self, provider: str, tenant_id: str) -> Optional[str]:
# Provider-specific first
for env_name in self._provider_env_candidates(provider):
value = os.environ.get(env_name)
@@ -119,11 +134,14 @@ class OnePasswordSecretProvider:
).strip()
def _item_template(self) -> str:
default_template = "openclaw/{provider}"
if is_multi_tenant_enabled():
default_template = "openclaw/{tenant}/{provider}"
return str(
_env_value(
"OPENCLAW_1PASSWORD_ITEM_TEMPLATE",
"MOLTBOT_1PASSWORD_ITEM_TEMPLATE",
"openclaw/{provider}",
default_template,
)
or ""
).strip()
@@ -182,16 +200,21 @@ class OnePasswordSecretProvider:
"S11: 1Password item template must include '{provider}'; fail-closed."
)
return False
if is_multi_tenant_enabled() and "{tenant}" not in template:
logger.warning(
"S49/S11: 1Password item template must include '{tenant}' in multi-tenant mode; fail-closed."
)
return False
return True
def _build_ref(self, provider: str) -> Optional[str]:
def _build_ref(self, provider: str, tenant_id: str) -> Optional[str]:
if not _PROVIDER_ID_RE.fullmatch(provider):
logger.warning(
"S11: Invalid provider id for 1Password lookup; fail-closed."
)
return None
template = self._item_template()
item = template.format(provider=provider)
item = template.format(provider=provider, tenant=tenant_id)
if not item or not _ITEM_RE.fullmatch(item) or ".." in item:
logger.warning("S11: 1Password item name is invalid; fail-closed.")
return None
@@ -237,18 +260,18 @@ class OnePasswordSecretProvider:
value = (completed.stdout or "").strip()
return value or None
def get_secret(self, provider: str) -> Optional[str]:
def get_secret(self, provider: str, tenant_id: str) -> Optional[str]:
if not self.is_available():
return None
provider_ref = self._build_ref(provider)
provider_ref = self._build_ref(provider, tenant_id)
if provider_ref:
value = self._read_ref(provider_ref, provider)
if value:
return value
if provider != "generic":
generic_ref = self._build_ref("generic")
generic_ref = self._build_ref("generic", tenant_id)
if generic_ref:
return self._read_ref(generic_ref, "generic")
return None
@@ -257,7 +280,7 @@ class OnePasswordSecretProvider:
class ServerStoreSecretProvider:
source = "server_store"
def get_secret(self, provider: str) -> Optional[str]:
def get_secret(self, provider: str, tenant_id: str) -> Optional[str]:
try:
from .secret_store import get_secret_store
except ImportError:
@@ -265,11 +288,11 @@ class ServerStoreSecretProvider:
try:
store = get_secret_store()
value = store.get_secret(provider)
value = store.get_secret(provider, tenant_id=tenant_id)
if value:
return value
if provider != "generic":
return store.get_secret("generic")
return store.get_secret("generic", tenant_id=tenant_id)
except Exception as exc:
logger.debug(
"S25/S11: secret store lookup failed (non-fatal): %s",
@@ -287,15 +310,21 @@ def get_secret_providers() -> list[SecretProvider]:
]
def resolve_provider_secret(provider: str) -> tuple[Optional[str], Optional[str]]:
def resolve_provider_secret(
provider: str, tenant_id: Optional[str] = None
) -> tuple[Optional[str], Optional[str]]:
"""
Resolve a provider secret through the configured provider chain.
Returns:
(secret, source) where source in {"env","onepassword","server_store"} or None.
"""
effective_tenant = normalize_tenant_id(
tenant_id or get_current_tenant_id() or DEFAULT_TENANT_ID
)
for resolver in get_secret_providers():
secret = resolver.get_secret(provider)
secret = resolver.get_secret(provider, effective_tenant)
if secret:
return secret, resolver.source
return None, None
+97 -11
View File
@@ -22,6 +22,34 @@ import threading
from pathlib import Path
from typing import Any, Dict, Optional
try:
from .tenant_context import (
DEFAULT_TENANT_ID,
get_current_tenant_id,
is_multi_tenant_enabled,
normalize_tenant_id,
)
except ImportError:
try:
from services.tenant_context import ( # type: ignore
DEFAULT_TENANT_ID,
get_current_tenant_id,
is_multi_tenant_enabled,
normalize_tenant_id,
)
except ImportError:
DEFAULT_TENANT_ID = "default"
def get_current_tenant_id(): # type: ignore
return DEFAULT_TENANT_ID
def is_multi_tenant_enabled(): # type: ignore
return False
def normalize_tenant_id(value): # type: ignore
return str(value or DEFAULT_TENANT_ID).strip().lower() or DEFAULT_TENANT_ID
try:
from .state_dir import get_state_dir
except ImportError:
@@ -76,6 +104,30 @@ class SecretStore:
self._encryption_key = enc._load_or_create_key(self._state_dir)
return self._encryption_key
def _resolve_tenant_id(self, tenant_id: Optional[str]) -> str:
if tenant_id is not None:
return normalize_tenant_id(tenant_id)
if is_multi_tenant_enabled():
try:
return normalize_tenant_id(get_current_tenant_id())
except Exception:
return DEFAULT_TENANT_ID
return DEFAULT_TENANT_ID
def _tenant_key(self, provider_id: str, tenant_id: Optional[str] = None) -> str:
tenant = self._resolve_tenant_id(tenant_id)
if tenant == DEFAULT_TENANT_ID:
return provider_id
return f"tenant::{tenant}::{provider_id}"
def _allow_legacy_fallback(self) -> bool:
value = (
os.environ.get("OPENCLAW_MULTI_TENANT_ALLOW_LEGACY_SECRET_FALLBACK")
or os.environ.get("MOLTBOT_MULTI_TENANT_ALLOW_LEGACY_SECRET_FALLBACK")
or "0"
)
return str(value).strip().lower() in ("1", "true", "yes", "on")
def _migrate_legacy(self) -> bool:
"""Migrate legacy plaintext secrets.json to encrypted format."""
if not self._legacy_path.exists():
@@ -178,7 +230,9 @@ class SecretStore:
logger.error(f"S57: Failed to save encrypted store: {e}")
raise
def get_secret(self, provider_id: str) -> Optional[str]:
def get_secret(
self, provider_id: str, tenant_id: Optional[str] = None
) -> Optional[str]:
"""
Get secret for provider.
@@ -188,10 +242,21 @@ class SecretStore:
Returns:
Secret value or None if not found
"""
tenant = self._resolve_tenant_id(tenant_id)
scoped_key = self._tenant_key(provider_id, tenant)
with self._lock:
return self._secrets.get(provider_id)
value = self._secrets.get(scoped_key)
if value:
return value
# S49: Optional compatibility fallback (explicit only) when tenant data
# has not been migrated yet.
if tenant != DEFAULT_TENANT_ID and self._allow_legacy_fallback():
return self._secrets.get(provider_id)
return None
def set_secret(self, provider_id: str, secret: str) -> None:
def set_secret(
self, provider_id: str, secret: str, tenant_id: Optional[str] = None
) -> None:
"""
Set secret for provider.
@@ -202,14 +267,18 @@ class SecretStore:
if not isinstance(secret, str) or not secret.strip():
raise ValueError("Secret must be non-empty string")
tenant = self._resolve_tenant_id(tenant_id)
scoped_key = self._tenant_key(provider_id, tenant)
with self._lock:
self._secrets[provider_id] = secret.strip()
self._secrets[scoped_key] = secret.strip()
self._save()
# Never log secret value
logger.info(f"S25: Set secret for provider '{provider_id}'")
logger.info(
"S25/S49: Set secret for provider '%s' (tenant=%s)", provider_id, tenant
)
def clear_secret(self, provider_id: str) -> bool:
def clear_secret(self, provider_id: str, tenant_id: Optional[str] = None) -> bool:
"""
Clear secret for provider.
@@ -219,11 +288,17 @@ class SecretStore:
Returns:
True if secret was removed, False if not found
"""
tenant = self._resolve_tenant_id(tenant_id)
scoped_key = self._tenant_key(provider_id, tenant)
with self._lock:
if provider_id in self._secrets:
del self._secrets[provider_id]
if scoped_key in self._secrets:
del self._secrets[scoped_key]
self._save()
logger.info(f"S25: Cleared secret for provider '{provider_id}'")
logger.info(
"S25/S49: Cleared secret for provider '%s' (tenant=%s)",
provider_id,
tenant,
)
return True
return False
@@ -241,16 +316,27 @@ class SecretStore:
logger.info(f"S25: Cleared all secrets ({count} total)")
return count
def get_status(self) -> Dict[str, Dict[str, Any]]:
def get_status(self, tenant_id: Optional[str] = None) -> Dict[str, Dict[str, Any]]:
"""
Get secret status (NO SECRET VALUES).
Returns:
Dict of {provider_id: {configured: bool, source: "server_store"}}
"""
tenant = self._resolve_tenant_id(tenant_id)
prefix = f"tenant::{tenant}::"
with self._lock:
status = {}
for provider_id in self._secrets.keys():
for key in self._secrets.keys():
provider_id = None
if tenant == DEFAULT_TENANT_ID:
if key.startswith("tenant::"):
continue
provider_id = key
elif key.startswith(prefix):
provider_id = key[len(prefix) :]
if not provider_id:
continue
status[provider_id] = {"configured": True, "source": "server_store"}
return status
+39 -2
View File
@@ -10,10 +10,16 @@ Loads manifest and renders templates for execution.
import copy
import logging
import os
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from .safe_io import resolve_under_root, safe_read_json
from .tenant_context import (
DEFAULT_TENANT_ID,
get_current_tenant_id,
is_multi_tenant_enabled,
normalize_tenant_id,
)
logger = logging.getLogger("ComfyUI-OpenClaw.services.templates")
@@ -31,6 +37,7 @@ class TemplateConfig:
path: str
allowed_inputs: List[str]
defaults: Dict[str, Any]
tenants: List[str] = field(default_factory=list)
class TemplateService:
@@ -105,10 +112,25 @@ class TemplateService:
self.manifest.clear()
for t_id, t_cfg in data.get("templates", {}).items():
tenants = t_cfg.get("tenants", [])
if not isinstance(tenants, list):
tenants = []
normalized_tenants: List[str] = []
for value in tenants:
try:
normalized_tenants.append(
normalize_tenant_id(value, field_name=f"{t_id}.tenants")
)
except Exception:
logger.warning(
"S49: ignoring invalid template tenant binding for %s",
t_id,
)
self.manifest[t_id] = TemplateConfig(
path=t_cfg["path"],
allowed_inputs=t_cfg.get("allowed_inputs", []),
defaults=t_cfg.get("defaults", {}),
tenants=normalized_tenants,
)
try:
self._manifest_mtime = os.path.getmtime(self._manifest_abspath)
@@ -146,11 +168,26 @@ class TemplateService:
def get_template_config(self, template_id: str) -> Optional[TemplateConfig]:
self._maybe_reload_manifest()
try:
tenant_id = normalize_tenant_id(get_current_tenant_id())
except Exception:
tenant_id = DEFAULT_TENANT_ID
cfg = self.manifest.get(template_id)
if cfg is not None:
if (
is_multi_tenant_enabled()
and cfg.tenants
and tenant_id not in cfg.tenants
):
return None
return cfg
# No manifest entry: treat `<template_id>.json` as runnable if present.
if is_multi_tenant_enabled():
# IMPORTANT: in multi-tenant mode, discovery-only templates are hidden by
# default because they cannot express explicit tenant visibility.
return None
rel_path = f"{template_id}.json"
try:
abs_path = resolve_under_root(self.templates_root, rel_path)
@@ -160,7 +197,7 @@ class TemplateService:
if not os.path.isfile(abs_path):
return None
return TemplateConfig(path=rel_path, allowed_inputs=[], defaults={})
return TemplateConfig(path=rel_path, allowed_inputs=[], defaults={}, tenants=[])
def render_template(
self, template_id: str, inputs: Dict[str, Any]
+204
View File
@@ -0,0 +1,204 @@
"""
S49 multi-tenant boundary context.
Central contract for tenant resolution, validation, and async context propagation.
Default mode remains single-tenant compatible.
"""
from __future__ import annotations
import contextlib
import contextvars
import os
import re
from dataclasses import dataclass
from typing import Any, Mapping, Optional
DEFAULT_TENANT_ID = "default"
TENANT_ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
_TRUTHY = {"1", "true", "yes", "on"}
_CURRENT_TENANT: contextvars.ContextVar[str] = contextvars.ContextVar(
"openclaw_current_tenant", default=DEFAULT_TENANT_ID
)
class TenantBoundaryError(ValueError):
"""Raised when tenant context cannot be resolved or verified."""
def __init__(self, code: str, message: str):
super().__init__(message)
self.code = code
@dataclass(frozen=True)
class TenantContext:
tenant_id: str = DEFAULT_TENANT_ID
source: str = "default"
multi_tenant: bool = False
def _is_truthy(value: Optional[str]) -> bool:
if value is None:
return False
return value.strip().lower() in _TRUTHY
def is_multi_tenant_enabled() -> bool:
return _is_truthy(
os.environ.get("OPENCLAW_MULTI_TENANT_ENABLED")
or os.environ.get("MOLTBOT_MULTI_TENANT_ENABLED")
or "0"
)
def allow_default_tenant_fallback() -> bool:
return _is_truthy(
os.environ.get("OPENCLAW_MULTI_TENANT_ALLOW_DEFAULT_FALLBACK")
or os.environ.get("MOLTBOT_MULTI_TENANT_ALLOW_DEFAULT_FALLBACK")
or "0"
)
def normalize_tenant_id(tenant_id: Any, *, field_name: str = "tenant_id") -> str:
text = str(tenant_id or "").strip().lower()
if not text:
raise TenantBoundaryError("tenant_invalid", f"{field_name} must be non-empty")
if not TENANT_ID_RE.fullmatch(text):
raise TenantBoundaryError(
"tenant_invalid",
f"{field_name} must match {TENANT_ID_RE.pattern}",
)
return text
def get_tenant_header_names() -> tuple[str, ...]:
configured = (
os.environ.get("OPENCLAW_TENANT_HEADER")
or os.environ.get("MOLTBOT_TENANT_HEADER")
or "X-OpenClaw-Tenant-Id"
).strip()
# Keep explicit legacy compatibility fallback.
return tuple(
dict.fromkeys(
[configured, "X-OpenClaw-Tenant-Id", "X-Moltbot-Tenant-Id"]
).keys()
)
def extract_tenant_from_headers(headers: Mapping[str, Any]) -> Optional[str]:
for key in get_tenant_header_names():
raw = headers.get(key) if headers else None
if raw is None:
continue
value = str(raw).strip()
if value:
return normalize_tenant_id(value, field_name=key)
return None
def resolve_tenant_context(
*,
request: Optional[Any] = None,
token_info: Optional[Any] = None,
allow_default_when_missing: bool = False,
) -> TenantContext:
"""
Resolve tenant context from token + request headers.
Resolution order (multi-tenant mode):
1) token_info.tenant_id
2) request tenant header
mismatch => fail-closed.
"""
multi_tenant = is_multi_tenant_enabled()
if not multi_tenant:
return TenantContext(
tenant_id=DEFAULT_TENANT_ID,
source="single_tenant_mode",
multi_tenant=False,
)
token_tenant = None
if token_info is not None and getattr(token_info, "tenant_id", None):
token_tenant = normalize_tenant_id(
getattr(token_info, "tenant_id"), field_name="token_tenant_id"
)
header_tenant = None
if request is not None and getattr(request, "headers", None) is not None:
header_tenant = extract_tenant_from_headers(request.headers)
if token_tenant and header_tenant and token_tenant != header_tenant:
raise TenantBoundaryError(
"tenant_mismatch",
"Tenant mismatch between token context and request header.",
)
if token_tenant:
return TenantContext(
tenant_id=token_tenant,
source="token",
multi_tenant=True,
)
if header_tenant:
return TenantContext(
tenant_id=header_tenant,
source="header",
multi_tenant=True,
)
if allow_default_when_missing or allow_default_tenant_fallback():
return TenantContext(
tenant_id=DEFAULT_TENANT_ID,
source="default_fallback",
multi_tenant=True,
)
raise TenantBoundaryError(
"tenant_required",
"Tenant context required in multi-tenant mode.",
)
def get_current_tenant_id() -> str:
if not is_multi_tenant_enabled():
return DEFAULT_TENANT_ID
tenant_id = _CURRENT_TENANT.get()
if not tenant_id:
return DEFAULT_TENANT_ID
return tenant_id
@contextlib.contextmanager
def tenant_scope(tenant_id: str):
"""Set tenant context in a contextvar scope (async-safe)."""
normalized = normalize_tenant_id(tenant_id)
token = _CURRENT_TENANT.set(normalized)
try:
yield normalized
finally:
_CURRENT_TENANT.reset(token)
@contextlib.contextmanager
def request_tenant_scope(
*,
request: Optional[Any] = None,
token_info: Optional[Any] = None,
allow_default_when_missing: bool = False,
):
"""
Resolve + bind tenant context for current request processing scope.
"""
ctx = resolve_tenant_context(
request=request,
token_info=token_info,
allow_default_when_missing=allow_default_when_missing,
)
token = _CURRENT_TENANT.set(ctx.tenant_id)
try:
yield ctx
finally:
_CURRENT_TENANT.reset(token)
+17
View File
@@ -9,6 +9,7 @@ import time
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch
# Set up test state directory
_repo_root = Path(__file__).resolve().parent.parent
@@ -293,6 +294,22 @@ class TestApprovalService(unittest.TestCase):
self.assertEqual(rejected.status, ApprovalStatus.REJECTED)
def test_multi_tenant_request_isolation(self):
"""S49: approval service should deny cross-tenant object access."""
from services.approvals.service import ApprovalService
service = ApprovalService()
with patch.dict("os.environ", {"OPENCLAW_MULTI_TENANT_ENABLED": "1"}):
request = service.create_request(
template_id="template_tenant_test",
inputs={},
tenant_id="tenant-a",
)
self.assertIsNone(service.get(request.approval_id, tenant_id="tenant-b"))
with self.assertRaises(ValueError):
service.approve(request.approval_id, tenant_id="tenant-b")
if __name__ == "__main__":
unittest.main()
@@ -1,6 +1,7 @@
import os
import tempfile
import unittest
from unittest.mock import patch
from services.connector_installation_registry import (
ConnectorInstallationRegistry,
@@ -137,6 +138,28 @@ class TestConnectorInstallationRegistry(unittest.TestCase):
).read()
self.assertNotIn("xoxb-secret", raw)
def test_multi_tenant_resolve_mismatch_fail_closed(self):
with patch.dict("os.environ", {"OPENCLAW_MULTI_TENANT_ENABLED": "1"}):
self.registry.upsert_installation(
platform="slack",
tenant_id="tenant-a",
workspace_id="T1",
installation_id="inst-tenant",
token_values={"bot_token": "xoxb-tenant"},
status=InstallationStatus.ACTIVE.value,
)
mismatch = self.registry.resolve_installation(
"slack", "T1", tenant_id="tenant-b"
)
self.assertFalse(mismatch.ok)
self.assertEqual(mismatch.reject_reason, "tenant_mismatch")
matched = self.registry.resolve_installation(
"slack", "T1", tenant_id="tenant-a"
)
self.assertTrue(matched.ok)
if __name__ == "__main__":
unittest.main()
+32
View File
@@ -33,6 +33,7 @@ class TestBudgetConfig(unittest.TestCase):
self.assertEqual(config.max_inflight_trigger, 1)
self.assertEqual(config.max_inflight_scheduler, 1)
self.assertEqual(config.max_inflight_bridge, 1)
self.assertEqual(config.max_inflight_per_tenant, 1)
self.assertEqual(config.max_rendered_workflow_bytes, 512 * 1024)
@patch.dict(
@@ -211,6 +212,37 @@ class TestExecutionBudgetLimiter(unittest.IsolatedAsyncioTestCase):
self.assertEqual(stats["total"], 1)
self.assertEqual(stats["unknown"], 1)
async def test_tenant_concurrency_cap_in_multi_tenant_mode(self):
"""S49: per-tenant concurrency cap should be fail-closed."""
from services.execution_budgets import BudgetConfig
config = BudgetConfig(
max_inflight_total=10,
max_inflight_webhook=10,
max_inflight_trigger=10,
max_inflight_scheduler=10,
max_inflight_bridge=10,
max_inflight_per_tenant=1,
max_rendered_workflow_bytes=512 * 1024,
)
limiter = ExecutionBudgetLimiter(config)
with patch.dict("os.environ", {"OPENCLAW_MULTI_TENANT_ENABLED": "1"}):
async with limiter.acquire("webhook", trace_id="trc_1", tenant_id="team-a"):
with self.assertRaises(BudgetExceededError) as ctx:
async with limiter.acquire(
"trigger", trace_id="trc_2", tenant_id="team-a"
):
pass
self.assertEqual(ctx.exception.budget_type, "tenant_concurrency")
# Different tenant should still be allowed under same global budget.
async with limiter.acquire(
"trigger", trace_id="trc_3", tenant_id="team-b"
):
stats = limiter.get_stats()
self.assertEqual(stats["total"], 2)
class TestGlobalLimiterSingleton(unittest.TestCase):
"""Test global limiter singleton."""
+18
View File
@@ -7,6 +7,7 @@ import shutil
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from services.presets.models import Preset
from services.presets.storage import PresetStore
@@ -77,6 +78,23 @@ class TestPresetStorage(unittest.TestCase):
loaded = store2.get_preset(p.id)
self.assertEqual(loaded.name, "Persistent")
def test_multi_tenant_visibility_filter(self):
"""S49: preset visibility must be tenant-isolated in multi-tenant mode."""
p1 = Preset.new("A", {})
p1.tenant_id = "tenant-a"
p2 = Preset.new("B", {})
p2.tenant_id = "tenant-b"
self.store.save_preset(p1)
self.store.save_preset(p2)
with patch.dict("os.environ", {"OPENCLAW_MULTI_TENANT_ENABLED": "1"}):
tenant_a = self.store.list_presets(tenant_id="tenant-a")
self.assertEqual(len(tenant_a), 1)
self.assertEqual(tenant_a[0].tenant_id, "tenant-a")
self.assertIsNone(self.store.get_preset(p2.id, tenant_id="tenant-a"))
self.assertFalse(self.store.delete_preset(p2.id, tenant_id="tenant-a"))
if __name__ == "__main__":
unittest.main()
+24
View File
@@ -291,6 +291,30 @@ class TestRuntimeConfig(unittest.TestCase):
with patch.dict(os.environ, {"OPENCLAW_ADMIN_TOKEN": "newsecret"}):
self.assertTrue(validate_admin_token("newsecret"))
def test_multi_tenant_config_namespace_isolation(self):
"""S49: persisted/runtime config should isolate tenant branches."""
from services.runtime_config import get_effective_config, update_config
from services.tenant_context import tenant_scope
with patch.dict(os.environ, {"OPENCLAW_MULTI_TENANT_ENABLED": "1"}):
with tenant_scope("tenant-a"):
ok, errors = update_config({"model": "model-a"})
self.assertTrue(ok)
self.assertEqual(errors, [])
effective_a, _ = get_effective_config()
self.assertEqual(effective_a["model"], "model-a")
with tenant_scope("tenant-b"):
ok, errors = update_config({"model": "model-b"})
self.assertTrue(ok)
self.assertEqual(errors, [])
effective_b, _ = get_effective_config()
self.assertEqual(effective_b["model"], "model-b")
with tenant_scope("tenant-a"):
effective_a, _ = get_effective_config()
self.assertEqual(effective_a["model"], "model-a")
if __name__ == "__main__":
unittest.main()
+20
View File
@@ -17,6 +17,7 @@ import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).parent.parent))
@@ -230,6 +231,25 @@ class TestKeyResolution(unittest.TestCase):
key = get_api_key_for_provider("openai")
self.assertEqual(key, "sk-store-key")
def test_multi_tenant_secret_isolation(self):
"""S49: secret store should isolate provider secrets per tenant."""
from services.secret_store import SecretStore
store = SecretStore(state_dir=self.test_dir)
with patch.dict("os.environ", {"OPENCLAW_MULTI_TENANT_ENABLED": "1"}):
store.set_secret("openai", "sk-tenant-a", tenant_id="tenant-a")
store.set_secret("openai", "sk-tenant-b", tenant_id="tenant-b")
self.assertEqual(
store.get_secret("openai", tenant_id="tenant-a"),
"sk-tenant-a",
)
self.assertEqual(
store.get_secret("openai", tenant_id="tenant-b"),
"sk-tenant-b",
)
self.assertIsNone(store.get_secret("openai", tenant_id="tenant-c"))
if __name__ == "__main__":
unittest.main()
+61
View File
@@ -0,0 +1,61 @@
import asyncio
import unittest
from unittest.mock import patch
from services.tenant_context import (
DEFAULT_TENANT_ID,
TenantBoundaryError,
get_current_tenant_id,
request_tenant_scope,
resolve_tenant_context,
tenant_scope,
)
class _Req:
def __init__(self, headers=None):
self.headers = headers or {}
class _Token:
def __init__(self, tenant_id):
self.tenant_id = tenant_id
class TestTenantContext(unittest.IsolatedAsyncioTestCase):
def test_single_tenant_default(self):
ctx = resolve_tenant_context(request=_Req({"X-OpenClaw-Tenant-Id": "team-a"}))
self.assertEqual(ctx.tenant_id, DEFAULT_TENANT_ID)
def test_multi_tenant_header_resolution(self):
with patch.dict("os.environ", {"OPENCLAW_MULTI_TENANT_ENABLED": "1"}):
ctx = resolve_tenant_context(request=_Req({"X-OpenClaw-Tenant-Id": "team-a"}))
self.assertEqual(ctx.tenant_id, "team-a")
def test_multi_tenant_mismatch_fail_closed(self):
with patch.dict("os.environ", {"OPENCLAW_MULTI_TENANT_ENABLED": "1"}):
with self.assertRaises(TenantBoundaryError) as exc:
resolve_tenant_context(
request=_Req({"X-OpenClaw-Tenant-Id": "team-b"}),
token_info=_Token("team-a"),
)
self.assertEqual(exc.exception.code, "tenant_mismatch")
async def test_async_context_propagation(self):
async def _read_tenant():
await asyncio.sleep(0)
return get_current_tenant_id()
with patch.dict("os.environ", {"OPENCLAW_MULTI_TENANT_ENABLED": "1"}):
with tenant_scope("team-a"):
value = await _read_tenant()
self.assertEqual(value, "team-a")
def test_request_scope_sets_current_tenant(self):
with patch.dict("os.environ", {"OPENCLAW_MULTI_TENANT_ENABLED": "1"}):
with request_tenant_scope(request=_Req({"X-OpenClaw-Tenant-Id": "team-a"})):
self.assertEqual(get_current_tenant_id(), "team-a")
if __name__ == "__main__":
unittest.main()
+29
View File
@@ -13,6 +13,7 @@ from unittest.mock import MagicMock, patch
sys.path.append(os.getcwd())
from services.templates import TemplateService
from services.tenant_context import tenant_scope
class TestTemplateService(unittest.TestCase):
@@ -84,6 +85,34 @@ class TestTemplateService(unittest.TestCase):
rendered = self.service.render_template("t2", {"input_any": "hello"})
self.assertEqual(rendered["node1"]["inputs"]["text"], "hello")
def test_multi_tenant_manifest_visibility(self):
"""S49: manifest tenant bindings should gate visibility in multi-tenant mode."""
manifest = {
"version": 1,
"templates": {
"t1": {
"path": "t1.json",
"allowed_inputs": ["input1"],
"tenants": ["tenant-a"],
}
},
}
with open(os.path.join(self.test_dir, "manifest.json"), "w") as f:
json.dump(manifest, f)
self.service._load_manifest()
with patch.dict("os.environ", {"OPENCLAW_MULTI_TENANT_ENABLED": "1"}):
with tenant_scope("tenant-a"):
self.assertIsNotNone(self.service.get_template_config("t1"))
with tenant_scope("tenant-b"):
self.assertIsNone(self.service.get_template_config("t1"))
def test_multi_tenant_hides_discovery_only_templates(self):
"""S49: discovery-only templates are hidden in multi-tenant mode."""
with patch.dict("os.environ", {"OPENCLAW_MULTI_TENANT_ENABLED": "1"}):
with tenant_scope("tenant-a"):
self.assertIsNone(self.service.get_template_config("t2"))
if __name__ == "__main__":
unittest.main()