mirror of
https://github.com/rookiestar28/ComfyUI-OpenClaw.git
synced 2026-08-14 08:52:45 +00:00
528 lines
16 KiB
Python
528 lines
16 KiB
Python
"""
|
|
Access Control Service (S14).
|
|
Provides secure-by-default access policies for observability endpoints.
|
|
"""
|
|
|
|
import datetime
|
|
import hmac
|
|
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
|
|
|
|
try:
|
|
from aiohttp import web
|
|
except ImportError:
|
|
web = None
|
|
|
|
from .legacy_compat import (
|
|
ADMIN_TOKEN_HEADERS,
|
|
OBS_TOKEN_HEADERS,
|
|
get_header_alias_value,
|
|
)
|
|
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:
|
|
from .endpoint_manifest import AuthTier, get_metadata
|
|
except ImportError:
|
|
# Fallback/Circular import handling
|
|
class AuthTier:
|
|
ADMIN = "admin"
|
|
OBSERVABILITY = "obs"
|
|
INTERNAL = "internal"
|
|
PUBLIC = "public"
|
|
WEBHOOK = "webhook"
|
|
|
|
def get_metadata(handler):
|
|
return None
|
|
|
|
|
|
logger = logging.getLogger("ComfyUI-OpenClaw.services.access_control")
|
|
|
|
|
|
def is_loopback(remote_addr: str) -> bool:
|
|
"""
|
|
Check if the remote address is a loopback address.
|
|
Supports IPv4 (127.0.0.0/8) and IPv6 (::1).
|
|
"""
|
|
if not remote_addr:
|
|
return False
|
|
|
|
# Simple string checks for common cases
|
|
if remote_addr == "127.0.0.1" or remote_addr == "::1" or remote_addr == "localhost":
|
|
return True
|
|
|
|
try:
|
|
ip = ipaddress.ip_address(remote_addr)
|
|
return ip.is_loopback
|
|
except ValueError:
|
|
# Invalid IP
|
|
return False
|
|
|
|
|
|
def is_auth_configured() -> bool:
|
|
"""
|
|
Check if Admin Token authentication is configured (S41).
|
|
Returns True if OPENCLAW_ADMIN_TOKEN/MOLTBOT_ADMIN_TOKEN is non-empty.
|
|
"""
|
|
# CRITICAL: keep OPENCLAW/MOLTBOT alias fallback chained with `or ... or ""`.
|
|
# Replacing with `and` (or removing the empty-string fallback) can produce None
|
|
# and break `.strip()`, which silently weakens mutation/adversarial gate coverage.
|
|
val = (
|
|
os.environ.get("OPENCLAW_ADMIN_TOKEN")
|
|
or os.environ.get("MOLTBOT_ADMIN_TOKEN")
|
|
or ""
|
|
)
|
|
return bool(val.strip())
|
|
|
|
|
|
def is_any_token_configured() -> bool:
|
|
"""
|
|
Check if ANY authentication token is configured (Admin OR Observability).
|
|
Used for S45 Startup Gate to assess if the instance has minimal protection.
|
|
"""
|
|
if is_auth_configured():
|
|
return True
|
|
|
|
# CRITICAL: same fallback invariant as admin token path above.
|
|
# Keep alias+default semantics deterministic for legacy compatibility and test gates.
|
|
obs_val = (
|
|
os.environ.get("OPENCLAW_OBSERVABILITY_TOKEN")
|
|
or os.environ.get("MOLTBOT_OBSERVABILITY_TOKEN")
|
|
or ""
|
|
)
|
|
return bool(obs_val.strip())
|
|
|
|
|
|
# --- S46 Token Infrastructure ---
|
|
|
|
|
|
@dataclass
|
|
class TokenInfo:
|
|
token_id: str
|
|
role: "AuthTier"
|
|
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."""
|
|
if "*" in self.scopes:
|
|
return True
|
|
if required in self.scopes:
|
|
return True
|
|
# Check prefixes (e.g. "read:*" matches "read:logs")
|
|
for s in self.scopes:
|
|
if s.endswith(":*"):
|
|
prefix = s[:-2]
|
|
if required.startswith(prefix + ":"):
|
|
return True
|
|
return False
|
|
|
|
|
|
class TokenRegistry:
|
|
"""
|
|
S46: Token Lifecycle Management.
|
|
Currently In-Memory. Future: Database.
|
|
"""
|
|
|
|
_tokens: Dict[str, TokenInfo] = {} # secret -> TokenInfo
|
|
|
|
@classmethod
|
|
def issue(
|
|
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]}",
|
|
role=role,
|
|
scopes=set(scopes),
|
|
created_at=now,
|
|
expires_at=expires,
|
|
tenant_id=normalized_tenant,
|
|
)
|
|
cls._tokens[secret] = info
|
|
return secret, info
|
|
|
|
@classmethod
|
|
def revoke(cls, token_id: str) -> bool:
|
|
"""Revoke a token by ID."""
|
|
to_delete = [s for s, i in cls._tokens.items() if i.token_id == token_id]
|
|
for s in to_delete:
|
|
del cls._tokens[s]
|
|
return len(to_delete) > 0
|
|
|
|
@classmethod
|
|
def lookup(cls, secret: str) -> Optional[TokenInfo]:
|
|
return cls._tokens.get(secret)
|
|
|
|
|
|
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
|
|
# IMPORTANT: do not tighten this to `tenant and DEFAULT_TENANT_ID`.
|
|
# Env-token auth must preserve explicit tenant header in multi-tenant mode.
|
|
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 = ""
|
|
client_token, _used_legacy_admin = get_header_alias_value(
|
|
headers, ADMIN_TOKEN_HEADERS, logger=logger
|
|
)
|
|
if not client_token:
|
|
client_token, _used_legacy_obs = get_header_alias_value(
|
|
headers, OBS_TOKEN_HEADERS, logger=logger
|
|
)
|
|
|
|
request_tenant = _resolve_header_tenant(request)
|
|
|
|
# 1. Registry Check
|
|
if client_token:
|
|
info = TokenRegistry.lookup(client_token)
|
|
if info:
|
|
return info
|
|
|
|
# 2. Static Env Check (Legacy/Bootstrap)
|
|
# Admin
|
|
# CRITICAL: preserve OPENCLAW->MOLTBOT alias fallback chain.
|
|
# This path must stay None-safe (`... or ""`) because we call `.strip()`.
|
|
admin_token = (
|
|
os.environ.get("OPENCLAW_ADMIN_TOKEN")
|
|
or os.environ.get("MOLTBOT_ADMIN_TOKEN")
|
|
or ""
|
|
).strip()
|
|
|
|
if admin_token and client_token:
|
|
if hmac.compare_digest(client_token, admin_token):
|
|
# IMPORTANT: keep request_tenant propagation here.
|
|
# Multi-tenant env-token requests must retain header-derived tenant context.
|
|
return TokenInfo(
|
|
token_id="env-admin",
|
|
role=AuthTier.ADMIN,
|
|
scopes={"*"},
|
|
tenant_id=request_tenant,
|
|
)
|
|
|
|
# Observability
|
|
# CRITICAL: preserve OPENCLAW->MOLTBOT alias fallback chain.
|
|
# This path must stay None-safe (`... or ""`) because we call `.strip()`.
|
|
obs_token = (
|
|
os.environ.get("OPENCLAW_OBSERVABILITY_TOKEN")
|
|
or os.environ.get("MOLTBOT_OBSERVABILITY_TOKEN")
|
|
or ""
|
|
).strip()
|
|
|
|
if obs_token and client_token:
|
|
# Note: If admin header was sent but matched Obs token, we accept it as Obs role?
|
|
# Ideally strict separation, but for now match value.
|
|
if hmac.compare_digest(client_token, obs_token):
|
|
return TokenInfo(
|
|
token_id="env-obs",
|
|
role=AuthTier.OBSERVABILITY,
|
|
scopes={"read:*"}, # S46: Wildcard for Obs
|
|
tenant_id=request_tenant,
|
|
)
|
|
|
|
# 3. Loopback
|
|
remote = get_client_ip(request)
|
|
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={"*"},
|
|
tenant_id=request_tenant,
|
|
)
|
|
else:
|
|
return TokenInfo(
|
|
token_id="local-internal",
|
|
role=AuthTier.INTERNAL,
|
|
scopes={"internal:call"},
|
|
tenant_id=request_tenant,
|
|
)
|
|
|
|
return None
|
|
|
|
|
|
def get_current_auth_tier(request) -> AuthTier:
|
|
"""
|
|
Determine the authentication tier of the current request.
|
|
Hierarchy: ADMIN > OBSERVABILITY > INTERNAL > PUBLIC.
|
|
"""
|
|
token_info = resolve_token_info(request)
|
|
if token_info:
|
|
return token_info.role
|
|
|
|
# No token? Check if public or internal loopback without token (if allowed?)
|
|
# Wait, resolve_token_info handles Loopback!
|
|
# If resolve_token_info returns None, it is strictly PUBLIC (Remote, No Token).
|
|
|
|
return AuthTier.PUBLIC
|
|
|
|
|
|
def verify_tier_access(request, required_tier: AuthTier) -> Tuple[bool, Optional[str]]:
|
|
"""
|
|
Check if the request meets the required AuthTier.
|
|
Enforces hierarchy: ADMIN > OBSERVABILITY > INTERNAL > PUBLIC.
|
|
"""
|
|
current_tier = get_current_auth_tier(request)
|
|
|
|
if required_tier == AuthTier.PUBLIC:
|
|
return True, None
|
|
|
|
# S46 Strict: Internal means "Local Network Only".
|
|
# Even Admin cannot access Internal endpoints from remote.
|
|
if required_tier == AuthTier.INTERNAL:
|
|
# We need to re-verify source IP because get_current_auth_tier abstracts it away into Roles.
|
|
# But wait, TokenInfo for Loopback has role=INTERNAL or ADMIN.
|
|
# TokenInfo for Remote Admin has role=ADMIN.
|
|
# If I am Remote Admin, my role is ADMIN.
|
|
# If I access INTERNAL endpoint, logic:
|
|
# if current == INTERNAL (Localhost): OK.
|
|
# if current == ADMIN (Remote): Fail?
|
|
|
|
# But wait, if Localhost is acting as Admin (Convenience Mode), role is ADMIN.
|
|
# So checking `current_tier == INTERNAL` might fail for Localhost Admin!
|
|
|
|
# We need to allow if underlying connection is Loopback.
|
|
remote = get_client_ip(request)
|
|
if is_loopback(remote):
|
|
return True, None
|
|
|
|
# R102 Hook
|
|
try:
|
|
from .security_telemetry import get_security_telemetry
|
|
|
|
get_security_telemetry().record_auth_failure(remote)
|
|
except ImportError:
|
|
pass
|
|
return False, "Internal (Loopback) access required."
|
|
|
|
# Admin is allowed everything else
|
|
if current_tier == AuthTier.ADMIN:
|
|
return True, None
|
|
|
|
if required_tier == AuthTier.ADMIN:
|
|
# Admin required. Current is not Admin (checked above).
|
|
# R102 Hook
|
|
try:
|
|
from .security_telemetry import get_security_telemetry
|
|
|
|
remote = get_client_ip(request)
|
|
get_security_telemetry().record_auth_failure(remote)
|
|
except ImportError:
|
|
pass
|
|
return False, "Admin access required."
|
|
|
|
if required_tier == AuthTier.OBSERVABILITY:
|
|
if current_tier in (
|
|
AuthTier.OBSERVABILITY,
|
|
AuthTier.ADMIN,
|
|
): # Admin covered, but explicit is fine
|
|
return True, None
|
|
# Internal Loopback?
|
|
# get_current_auth_tier converts Loopback -> INTERNAL (or ADMIN).
|
|
# If Loopback is INTERNAL, does it satisfy OBS?
|
|
# Yes, Loopback should satisfy Obs.
|
|
if current_tier == AuthTier.INTERNAL:
|
|
return True, None
|
|
|
|
# R102 Hook
|
|
try:
|
|
from .security_telemetry import get_security_telemetry
|
|
|
|
remote = get_client_ip(request)
|
|
get_security_telemetry().record_auth_failure(remote)
|
|
except ImportError:
|
|
pass
|
|
return False, "Observability access required."
|
|
|
|
# R102 Hook for generic failure
|
|
try:
|
|
from .security_telemetry import get_security_telemetry
|
|
|
|
remote = get_client_ip(request)
|
|
get_security_telemetry().record_auth_failure(remote)
|
|
except ImportError:
|
|
pass
|
|
return False, f"Access denied. Required: {required_tier}, Current: {current_tier}"
|
|
|
|
|
|
def verify_scope_access(
|
|
request, required_scopes: List[str]
|
|
) -> Tuple[bool, Optional[str]]:
|
|
"""
|
|
Verify that the request has ALL required scopes.
|
|
"""
|
|
if not required_scopes:
|
|
return True, None
|
|
|
|
token_info = resolve_token_info(request)
|
|
if not token_info:
|
|
return False, "Authentication required for scoped access."
|
|
|
|
missing = []
|
|
for req in required_scopes:
|
|
if not token_info.has_scope(req):
|
|
missing.append(req)
|
|
|
|
if missing:
|
|
return False, f"Missing required scopes: {', '.join(missing)}"
|
|
|
|
return True, None
|
|
|
|
|
|
def enforce_security(handler):
|
|
"""
|
|
S46 Decorator: Per-handler scope enforcement.
|
|
Wraps an aiohttp handler to enforce AuthTier and Scope requirements defined in metadata.
|
|
"""
|
|
import functools
|
|
|
|
@functools.wraps(handler)
|
|
async def wrapper(request, *args, **kwargs):
|
|
meta = get_metadata(handler)
|
|
if not meta:
|
|
# S99: Drift Detection - Unclassified endpoint!
|
|
return web.Response(status=403, text="Access Denied: Unclassified Endpoint")
|
|
|
|
# 1. Tier Check
|
|
passed, err = verify_tier_access(request, meta.auth_tier)
|
|
if not passed:
|
|
return web.Response(status=403, text=f"Access Denied: {err}")
|
|
|
|
# 2. Scope Check (S46)
|
|
if meta.required_scopes:
|
|
passed, err = verify_scope_access(request, meta.required_scopes)
|
|
if not passed:
|
|
return web.Response(status=403, text=f"Forbidden: {err}")
|
|
|
|
return await handler(request, *args, **kwargs)
|
|
|
|
return wrapper
|
|
|
|
|
|
# --- Legacy Support (Keep until refactor complete) ---
|
|
# CRITICAL: keep legacy wrappers behavior/message-compatible with S13/S14/S27 contracts.
|
|
# Do not replace these wrappers with direct tier/scope checks; loopback CSRF semantics would regress.
|
|
|
|
|
|
def require_observability_access(request) -> Tuple[bool, Optional[str]]:
|
|
"""
|
|
Enforce S14 access control policy for observability endpoints.
|
|
|
|
Keep legacy behavior/messages stable for existing handlers/tests:
|
|
1. Loopback -> allow
|
|
2. Valid observability token -> allow
|
|
3. Otherwise -> deny
|
|
"""
|
|
remote = get_client_ip(request)
|
|
if is_loopback(remote):
|
|
return True, None
|
|
|
|
expected_token = (
|
|
os.environ.get("OPENCLAW_OBSERVABILITY_TOKEN")
|
|
or os.environ.get("MOLTBOT_OBSERVABILITY_TOKEN")
|
|
or ""
|
|
).strip()
|
|
if expected_token:
|
|
client_token, _used_legacy = get_header_alias_value(
|
|
request.headers, OBS_TOKEN_HEADERS, logger=logger
|
|
)
|
|
if hmac.compare_digest(client_token, expected_token):
|
|
return True, None
|
|
return False, "Invalid or missing observability token."
|
|
|
|
return (
|
|
False,
|
|
"Remote access denied. Set OPENCLAW_OBSERVABILITY_TOKEN (or legacy MOLTBOT_OBSERVABILITY_TOKEN) to allow.",
|
|
)
|
|
|
|
|
|
def require_admin_token(request) -> Tuple[bool, Optional[str]]:
|
|
"""
|
|
Enforce token-based access for administrative/write actions.
|
|
|
|
Keep S13/S27 legacy behavior stable:
|
|
- If admin token configured -> require matching token header.
|
|
- If no admin token configured -> allow loopback with same-origin CSRF check.
|
|
- Deny remote by default.
|
|
"""
|
|
remote = get_client_ip(request)
|
|
expected_token = (
|
|
os.environ.get("OPENCLAW_ADMIN_TOKEN")
|
|
or os.environ.get("MOLTBOT_ADMIN_TOKEN")
|
|
or ""
|
|
).strip()
|
|
if expected_token:
|
|
client_token, _used_legacy = get_header_alias_value(
|
|
request.headers, ADMIN_TOKEN_HEADERS, logger=logger
|
|
)
|
|
if hmac.compare_digest(client_token, expected_token):
|
|
return True, None
|
|
return False, "Invalid admin token."
|
|
|
|
# No token configured: loopback convenience with S27 CSRF protection.
|
|
if is_loopback(remote):
|
|
try:
|
|
from .csrf_protection import is_same_origin_request
|
|
except ImportError:
|
|
try:
|
|
from services.csrf_protection import is_same_origin_request
|
|
except ImportError:
|
|
logger.warning(
|
|
"S27: CSRF protection module missing, allowing loopback (unsafe)"
|
|
)
|
|
return True, None
|
|
|
|
if not is_same_origin_request(request):
|
|
return (
|
|
False,
|
|
"Cross-origin request denied (S33). Set OPENCLAW_ADMIN_TOKEN to use token-based auth.",
|
|
)
|
|
return True, None
|
|
|
|
return (
|
|
False,
|
|
"Remote admin access denied. Set OPENCLAW_ADMIN_TOKEN (or legacy MOLTBOT_ADMIN_TOKEN) to allow.",
|
|
)
|