diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py index 20e412e92..0f8f1bc41 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -773,8 +773,7 @@ async def generate_or_reset_api_key( raise HTTPException(status_code=400, detail="API keys are only available for OpenClaw agents") raw_key = f"oc-{secrets.token_urlsafe(32)}" - # Store in plaintext so frontend can retrieve it anytime to display and copy - agent.api_key_hash = raw_key + agent.api_key_hash = hashlib.sha256(raw_key.encode()).hexdigest() await db.commit() return {"api_key": raw_key, "message": "Key configured successfully."} diff --git a/backend/app/api/enterprise.py b/backend/app/api/enterprise.py index 25d98c802..78b6006ce 100644 --- a/backend/app/api/enterprise.py +++ b/backend/app/api/enterprise.py @@ -11,7 +11,8 @@ from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession -from app.core.security import get_current_admin, get_current_user, require_role +from app.config import get_settings +from app.core.security import get_current_admin, get_current_user, require_role, encrypt_data from app.database import get_db from app.models.org import OrgDepartment, OrgMember from app.models.identity import IdentityProvider @@ -26,11 +27,12 @@ ) from app.services.autonomy_service import autonomy_service from app.services.enterprise_sync import enterprise_sync_service -from app.services.llm_utils import get_provider_manifest +from app.services.llm_utils import get_provider_manifest, get_model_api_key from app.services.platform_service import platform_service from app.services.sso_service import sso_service router = APIRouter(prefix="/enterprise", tags=["enterprise"]) +settings = get_settings() # ─── Public: Check Email Exists ──────────────────────── @@ -90,7 +92,7 @@ async def test_llm_model( result = await db.execute(select(LLMModel).where(LLMModel.id == data.model_id)) existing = result.scalar_one_or_none() if existing: - api_key = existing.api_key_encrypted + api_key = get_model_api_key(existing) if not api_key: return {"success": False, "latency_ms": 0, "error": "API Key is required"} @@ -138,7 +140,7 @@ async def list_llm_models( for m in result.scalars().all(): out = LLMModelOut.model_validate(m) # Mask API key: show last 4 chars - key = m.api_key_encrypted or "" + key = get_model_api_key(m) out.api_key_masked = f"****{key[-4:]}" if len(key) > 4 else "****" models.append(out) return models @@ -156,7 +158,7 @@ async def add_llm_model( model = LLMModel( provider=data.provider, model=data.model, - api_key_encrypted=data.api_key, # TODO: encrypt + api_key_encrypted=encrypt_data(data.api_key, settings.SECRET_KEY), base_url=data.base_url, label=data.label, temperature=data.temperature, @@ -238,7 +240,7 @@ async def update_llm_model( if hasattr(data, 'base_url') and data.base_url is not None: model.base_url = data.base_url if data.api_key and data.api_key.strip() and not data.api_key.startswith('****'): # Skip masked values - model.api_key_encrypted = data.api_key.strip() + model.api_key_encrypted = encrypt_data(data.api_key.strip(), settings.SECRET_KEY) if data.temperature is not None: model.temperature = data.temperature if data.max_tokens_per_day is not None: diff --git a/backend/app/api/gateway.py b/backend/app/api/gateway.py index 0f6f6010e..bbbe9de6a 100644 --- a/backend/app/api/gateway.py +++ b/backend/app/api/gateway.py @@ -6,13 +6,12 @@ import asyncio import hashlib -import secrets import uuid from datetime import datetime, timezone -from fastapi import APIRouter, Header, HTTPException, Depends, BackgroundTasks +from fastapi import APIRouter, Header, HTTPException, Depends from loguru import logger -from sqlalchemy import select, update +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db, async_session @@ -59,42 +58,6 @@ async def _get_agent_by_key(api_key: str, db: AsyncSession) -> Agent: return agent -# ─── Generate / Regenerate API Key ────────────────────── - -@router.post("/generate-key/{agent_id}") -async def generate_api_key( - agent_id: uuid.UUID, - db: AsyncSession = Depends(get_db), - # JWT auth for this endpoint (requires the agent creator) - current_user: "User" = Depends(None), # placeholder, will use real dependency -): - """Generate or regenerate an API key for an OpenClaw agent. - - Called from the frontend by the agent creator. - """ - from app.api.agents import get_current_user - raise HTTPException(status_code=501, detail="Use the /agents/{id}/api-key endpoint instead") - - -@router.post("/agents/{agent_id}/api-key") -async def generate_agent_api_key(agent_id: uuid.UUID, db: AsyncSession = Depends(get_db)): - """Generate or regenerate API key for an OpenClaw agent. - - This is an internal endpoint called by the agents API. - """ - result = await db.execute(select(Agent).where(Agent.id == agent_id, Agent.agent_type == "openclaw")) - agent = result.scalar_one_or_none() - if not agent: - raise HTTPException(status_code=404, detail="OpenClaw agent not found") - - # Generate a new key - raw_key = f"oc-{secrets.token_urlsafe(32)}" - agent.api_key_hash = _hash_key(raw_key) - await db.commit() - - return {"api_key": raw_key, "message": "Save this key — it won't be shown again."} - - # ─── Poll for messages ────────────────────────────────── @router.get("/poll", response_model=GatewayPollResponse) diff --git a/backend/app/api/plaza.py b/backend/app/api/plaza.py index 6762368d0..aa3aec5f1 100644 --- a/backend/app/api/plaza.py +++ b/backend/app/api/plaza.py @@ -129,12 +129,22 @@ async def _notify_mentions(db, content: str, author_id: uuid.UUID, author_name: # ── Routes ────────────────────────────────────────── @router.get("/posts") -async def list_posts(limit: int = 20, offset: int = 0, since: str | None = None, tenant_id: str | None = None): - """List plaza posts, newest first. Filtered by tenant_id for data isolation.""" +async def list_posts( + limit: int = 20, + offset: int = 0, + since: str | None = None, + tenant_id: str | None = None, + current_user: User = Depends(get_current_user), +): + """List plaza posts, newest first. Filtered by tenant_id from JWT for data isolation.""" + # Enforce tenant from JWT; platform_admin can optionally specify a different tenant + effective_tenant_id = str(current_user.tenant_id) if current_user.tenant_id else None + if tenant_id and current_user.role == "platform_admin": + effective_tenant_id = tenant_id async with async_session() as db: q = select(PlazaPost).order_by(desc(PlazaPost.created_at)) - if tenant_id: - q = q.where(PlazaPost.tenant_id == tenant_id) + if effective_tenant_id: + q = q.where(PlazaPost.tenant_id == effective_tenant_id) if since: try: since_dt = datetime.fromisoformat(since.replace("Z", "+00:00")) @@ -148,25 +158,32 @@ async def list_posts(limit: int = 20, offset: int = 0, since: str | None = None, @router.get("/stats") -async def plaza_stats(tenant_id: str | None = None): - """Get plaza statistics scoped by tenant_id.""" +async def plaza_stats( + tenant_id: str | None = None, + current_user: User = Depends(get_current_user), +): + """Get plaza statistics scoped by tenant_id from JWT.""" + # Enforce tenant from JWT; platform_admin can optionally specify a different tenant + effective_tenant_id = str(current_user.tenant_id) if current_user.tenant_id else None + if tenant_id and current_user.role == "platform_admin": + effective_tenant_id = tenant_id async with async_session() as db: # Build base filters - post_filter = PlazaPost.tenant_id == tenant_id if tenant_id else True + post_filter = PlazaPost.tenant_id == effective_tenant_id if effective_tenant_id else True # Total posts total_posts = (await db.execute( select(func.count(PlazaPost.id)).where(post_filter) )).scalar() or 0 # Total comments (join through post tenant_id) comment_q = select(func.count(PlazaComment.id)) - if tenant_id: - comment_q = comment_q.join(PlazaPost, PlazaComment.post_id == PlazaPost.id).where(PlazaPost.tenant_id == tenant_id) + if effective_tenant_id: + comment_q = comment_q.join(PlazaPost, PlazaComment.post_id == PlazaPost.id).where(PlazaPost.tenant_id == effective_tenant_id) total_comments = (await db.execute(comment_q)).scalar() or 0 # Today's posts today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) today_q = select(func.count(PlazaPost.id)).where(PlazaPost.created_at >= today_start) - if tenant_id: - today_q = today_q.where(PlazaPost.tenant_id == tenant_id) + if effective_tenant_id: + today_q = today_q.where(PlazaPost.tenant_id == effective_tenant_id) today_posts = (await db.execute(today_q)).scalar() or 0 # Top 5 contributors by post count top_q = ( @@ -190,24 +207,24 @@ async def plaza_stats(tenant_id: str | None = None): @router.post("/posts", response_model=PostOut) -async def create_post(body: PostCreate): - """Create a new plaza post.""" +async def create_post(body: PostCreate, current_user: User = Depends(get_current_user)): + """Create a new plaza post. Requires authentication; tenant_id enforced from JWT.""" if len(body.content.strip()) == 0: raise HTTPException(400, "Content cannot be empty") + effective_tenant_id = str(current_user.tenant_id) if current_user.tenant_id else None async with async_session() as db: post = PlazaPost( author_id=body.author_id, author_type=body.author_type, author_name=body.author_name, content=body.content[:500], - tenant_id=body.tenant_id, + tenant_id=effective_tenant_id, ) db.add(post) - await db.flush() # get post.id before commit + await db.flush() - # Extract @mentions and notify try: - await _notify_mentions(db, body.content, body.author_id, body.author_name, post.id, body.tenant_id) + await _notify_mentions(db, body.content, body.author_id, body.author_name, post.id, effective_tenant_id) except Exception: pass @@ -217,14 +234,17 @@ async def create_post(body: PostCreate): @router.get("/posts/{post_id}", response_model=PostDetail) -async def get_post(post_id: uuid.UUID): - """Get a single post with its comments.""" +async def get_post(post_id: uuid.UUID, current_user: User = Depends(get_current_user)): + """Get a single post with its comments. Enforces tenant isolation.""" + effective_tenant_id = str(current_user.tenant_id) if current_user.tenant_id else None async with async_session() as db: - result = await db.execute(select(PlazaPost).where(PlazaPost.id == post_id)) + q = select(PlazaPost).where(PlazaPost.id == post_id) + if effective_tenant_id and current_user.role != "platform_admin": + q = q.where(PlazaPost.tenant_id == effective_tenant_id) + result = await db.execute(q) post = result.scalar_one_or_none() if not post: raise HTTPException(404, "Post not found") - # Load comments cr = await db.execute( select(PlazaComment).where(PlazaComment.post_id == post_id).order_by(PlazaComment.created_at) ) @@ -236,17 +256,20 @@ async def get_post(post_id: uuid.UUID): @router.delete("/posts/{post_id}") async def delete_post(post_id: uuid.UUID, current_user: User = Depends(get_current_user)): - """Delete a plaza post. Admins can delete any post; authors can delete their own.""" + """Delete a plaza post. Admins can delete any post; authors can delete their own. Enforces tenant isolation.""" + effective_tenant_id = str(current_user.tenant_id) if current_user.tenant_id else None async with async_session() as db: result = await db.execute(select(PlazaPost).where(PlazaPost.id == post_id)) post = result.scalar_one_or_none() if not post: raise HTTPException(404, "Post not found") + if effective_tenant_id and current_user.role != "platform_admin": + if str(post.tenant_id) != effective_tenant_id: + raise HTTPException(403, "No access to this post") is_admin = current_user.role in ("platform_admin", "org_admin") is_author = post.author_id == current_user.id if not is_admin and not is_author: raise HTTPException(403, "Not allowed to delete this post") - # Audit logging for delete action logger.info(f"Plaza post {post_id} deleted by user {current_user.id} (admin={is_admin})") await db.delete(post) await db.commit() @@ -254,16 +277,19 @@ async def delete_post(post_id: uuid.UUID, current_user: User = Depends(get_curre @router.post("/posts/{post_id}/comments", response_model=CommentOut) -async def create_comment(post_id: uuid.UUID, body: CommentCreate): - """Add a comment to a post.""" +async def create_comment(post_id: uuid.UUID, body: CommentCreate, current_user: User = Depends(get_current_user)): + """Add a comment to a post. Requires authentication; enforces tenant isolation.""" if len(body.content.strip()) == 0: raise HTTPException(400, "Content cannot be empty") + effective_tenant_id = str(current_user.tenant_id) if current_user.tenant_id else None async with async_session() as db: - # Verify post exists result = await db.execute(select(PlazaPost).where(PlazaPost.id == post_id)) post = result.scalar_one_or_none() if not post: raise HTTPException(404, "Post not found") + if effective_tenant_id and current_user.role != "platform_admin": + if str(post.tenant_id) != effective_tenant_id: + raise HTTPException(403, "No access to this post") comment = PlazaComment( post_id=post_id, @@ -362,10 +388,17 @@ async def create_comment(post_id: uuid.UUID, body: CommentCreate): @router.post("/posts/{post_id}/like") -async def like_post(post_id: uuid.UUID, author_id: uuid.UUID, author_type: str = "human"): - """Like a post (toggle).""" +async def like_post(post_id: uuid.UUID, author_id: uuid.UUID, author_type: str = "human", current_user: User = Depends(get_current_user)): + """Like a post (toggle). Requires authentication; enforces tenant isolation.""" + effective_tenant_id = str(current_user.tenant_id) if current_user.tenant_id else None async with async_session() as db: - # Check existing like + result = await db.execute(select(PlazaPost).where(PlazaPost.id == post_id)) + post = result.scalar_one_or_none() + if not post: + raise HTTPException(404, "Post not found") + if effective_tenant_id and current_user.role != "platform_admin": + if str(post.tenant_id) != effective_tenant_id: + raise HTTPException(403, "No access to this post") existing = await db.execute( select(PlazaLike).where(PlazaLike.post_id == post_id, PlazaLike.author_id == author_id) ) diff --git a/backend/app/api/websocket.py b/backend/app/api/websocket.py index ad57d3a36..2f14cfff0 100644 --- a/backend/app/api/websocket.py +++ b/backend/app/api/websocket.py @@ -131,7 +131,7 @@ async def call_llm( on_tool_call: Optional async callback(dict) for tool call status updates. """ from app.services.agent_tools import AGENT_TOOLS, execute_tool, get_agent_tools_for_llm - from app.services.llm_utils import create_llm_client, get_max_tokens, LLMMessage, LLMError + from app.services.llm_utils import create_llm_client, get_max_tokens, LLMMessage, LLMError, get_model_api_key # ── Token limit check & config ── _max_tool_rounds = 50 # default @@ -231,7 +231,7 @@ async def call_llm( try: client = create_llm_client( provider=model.provider, - api_key=model.api_key_encrypted, + api_key=get_model_api_key(model), model=model.model, base_url=model.base_url, timeout=float(getattr(model, 'request_timeout', None) or 120.0), diff --git a/backend/app/core/permissions.py b/backend/app/core/permissions.py index aada43aa4..63e551ad0 100644 --- a/backend/app/core/permissions.py +++ b/backend/app/core/permissions.py @@ -31,6 +31,10 @@ async def check_agent_access(db: AsyncSession, user: User, agent_id: uuid.UUID) if user.role == "platform_admin": return agent, "manage" + # Tenant isolation: non-platform-admin users can only access agents in their own tenant + if agent.tenant_id != user.tenant_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No access to this agent") + # Creator always has manage access if agent.creator_id == user.id: return agent, "manage" diff --git a/backend/app/main.py b/backend/app/main.py index c215a684f..896976db5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -72,6 +72,13 @@ async def lifespan(app: FastAPI): intercept_standard_logging() logger.info("[startup] Logging configured") + # Warn about default JWT secrets in production + if "change-me" in settings.SECRET_KEY.lower() or "change-me" in settings.JWT_SECRET_KEY.lower(): + logger.warning( + "[startup] WARNING: SECRET_KEY or JWT_SECRET_KEY contains default 'change-me' value. " + "This is insecure for production. Set unique secrets in your .env file." + ) + import asyncio import sys import os diff --git a/backend/app/services/agent_manager.py b/backend/app/services/agent_manager.py index ad93b3516..a01d4bb4f 100644 --- a/backend/app/services/agent_manager.py +++ b/backend/app/services/agent_manager.py @@ -15,6 +15,7 @@ from app.config import get_settings from app.models.agent import Agent from app.models.llm import LLMModel +from app.services.llm_utils import get_model_api_key settings = get_settings() @@ -152,9 +153,9 @@ def _generate_openclaw_config(self, agent: Agent, model: LLMModel | None) -> dic }, } - if model and model.api_key_encrypted: + if model: config["env"] = { - f"{model.provider.upper()}_API_KEY": model.api_key_encrypted, + f"{model.provider.upper()}_API_KEY": get_model_api_key(model), } return config diff --git a/backend/app/services/agent_tools.py b/backend/app/services/agent_tools.py index adbdb9408..dfb389f78 100644 --- a/backend/app/services/agent_tools.py +++ b/backend/app/services/agent_tools.py @@ -5130,6 +5130,7 @@ async def _send_message_to_agent(from_agent_id: uuid.UUID, args: dict) -> str: get_provider_base_url, create_llm_client, LLMMessage, + get_model_api_key, ) from app.services.llm_client import LLMError from app.services.agent_tools import get_agent_tools_for_llm, execute_tool @@ -5152,7 +5153,7 @@ async def _send_message_to_agent(from_agent_id: uuid.UUID, args: dict) -> str: llm_client = create_llm_client( provider=target_model.provider, - api_key=target_model.api_key_encrypted, + api_key=get_model_api_key(target_model), model=target_model.model, base_url=base_url, timeout=float(getattr(target_model, 'request_timeout', None) or 120.0), diff --git a/backend/app/services/heartbeat.py b/backend/app/services/heartbeat.py index 5a45f54c2..6afcf1a82 100644 --- a/backend/app/services/heartbeat.py +++ b/backend/app/services/heartbeat.py @@ -162,7 +162,7 @@ async def _execute_heartbeat(agent_id: uuid.UUID): agent_role = agent.role_description or "" agent_creator_id = agent.creator_id model_provider = model.provider - model_api_key = model.api_key_encrypted + model_api_key = get_model_api_key(model) model_model = model.model model_base_url = model.base_url model_temperature = model.temperature @@ -254,7 +254,7 @@ async def _execute_heartbeat(agent_id: uuid.UUID): full_instruction = heartbeat_instruction + recent_context + inbox_context # Call LLM with tools using unified client - from app.services.llm_utils import create_llm_client, get_max_tokens, LLMMessage, LLMError + from app.services.llm_utils import create_llm_client, get_max_tokens, LLMMessage, LLMError, get_model_api_key from app.services.agent_tools import execute_tool, get_agent_tools_for_llm try: diff --git a/backend/app/services/llm_utils.py b/backend/app/services/llm_utils.py index 9cde9454f..bd827d0fb 100644 --- a/backend/app/services/llm_utils.py +++ b/backend/app/services/llm_utils.py @@ -8,6 +8,10 @@ for convenient access. """ +from app.core.security import decrypt_data +from app.config import get_settings +from app.models.llm import LLMModel + # Re-export all client classes and functions from llm_client.py from app.services.llm_client import ( AnthropicClient, @@ -42,6 +46,18 @@ # Keep the original PROVIDER_URLS reference (already exported from llm_client) +def get_model_api_key(model: LLMModel) -> str: + """Decrypt the model's API key, with backward compatibility for plaintext keys.""" + raw = model.api_key_encrypted or "" + if not raw: + return "" + try: + settings = get_settings() + return decrypt_data(raw, settings.SECRET_KEY) + except ValueError: + return raw + + def get_tool_params(provider: str) -> dict: """Return provider-specific tool calling parameters. diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py index 4f5f67daf..f89e72764 100644 --- a/backend/app/services/scheduler.py +++ b/backend/app/services/scheduler.py @@ -64,7 +64,7 @@ async def _execute_schedule(schedule_id: uuid.UUID, agent_id: uuid.UUID, instruc # Build context and call LLM from app.services.agent_context import build_agent_context from app.services.agent_tools import execute_tool, get_agent_tools_for_llm - from app.services.llm_utils import create_llm_client, get_max_tokens, LLMMessage, LLMError + from app.services.llm_utils import create_llm_client, get_max_tokens, LLMMessage, LLMError, get_model_api_key static_prompt, dynamic_prompt = await build_agent_context(agent_id, agent.name, agent.role_description or "") @@ -80,7 +80,7 @@ async def _execute_schedule(schedule_id: uuid.UUID, agent_id: uuid.UUID, instruc try: client = create_llm_client( provider=model.provider, - api_key=model.api_key_encrypted, + api_key=get_model_api_key(model), model=model.model, base_url=model.base_url, timeout=float(getattr(model, 'request_timeout', None) or 120.0), diff --git a/backend/app/services/supervision_reminder.py b/backend/app/services/supervision_reminder.py index 5b57ab04f..9464dbe07 100644 --- a/backend/app/services/supervision_reminder.py +++ b/backend/app/services/supervision_reminder.py @@ -110,6 +110,7 @@ async def _get_agent_reply(target_agent, message: str, db) -> str | None: create_llm_client, LLMMessage, LLMError, + get_model_api_key, ) model_id = target_agent.primary_model_id or target_agent.fallback_model_id @@ -137,7 +138,7 @@ async def _get_agent_reply(target_agent, message: str, db) -> str | None: client = create_llm_client( provider=model.provider, - api_key=model.api_key_encrypted, + api_key=get_model_api_key(model), model=model.model, base_url=base_url, timeout=float(getattr(model, 'request_timeout', None) or 60.0), diff --git a/backend/app/services/task_executor.py b/backend/app/services/task_executor.py index 1097d3994..9617286f4 100644 --- a/backend/app/services/task_executor.py +++ b/backend/app/services/task_executor.py @@ -12,11 +12,14 @@ from loguru import logger from sqlalchemy import select +from app.config import get_settings from app.database import async_session from app.models.agent import Agent from app.models.llm import LLMModel from app.models.task import Task, TaskLog +settings = get_settings() + async def execute_task(task_id: uuid.UUID, agent_id: uuid.UUID) -> None: """Execute a task using the agent's configured LLM with full context. @@ -64,7 +67,7 @@ async def execute_task(task_id: uuid.UUID, agent_id: uuid.UUID) -> None: return model_result = await db.execute( - select(LLMModel).where(LLMModel.id == model_id) + select(LLMModel).where(LLMModel.id == model_id, LLMModel.tenant_id == agent.tenant_id) ) model = model_result.scalar_one_or_none() if not model: @@ -111,7 +114,7 @@ async def execute_task(task_id: uuid.UUID, agent_id: uuid.UUID) -> None: user_prompt += "\n\n请认真完成此任务,给出详细的执行结果。" # Step 4: Call LLM with tool loop - from app.services.llm_utils import create_llm_client, get_max_tokens, LLMMessage, LLMError + from app.services.llm_utils import create_llm_client, get_max_tokens, LLMMessage, LLMError, get_model_api_key messages = [ LLMMessage(role="system", content=static_prompt, dynamic_content=dynamic_prompt), @@ -129,7 +132,7 @@ async def execute_task(task_id: uuid.UUID, agent_id: uuid.UUID) -> None: try: client = create_llm_client( provider=model.provider, - api_key=model.api_key_encrypted, + api_key=get_model_api_key(model), model=model.model, base_url=model.base_url, timeout=float(getattr(model, 'request_timeout', None) or 1200.0),