diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py index f83729b2..de27b50c 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -764,8 +764,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 ad71d669..713346a3 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 @@ -31,6 +32,7 @@ from app.services.sso_service import sso_service router = APIRouter(prefix="/enterprise", tags=["enterprise"]) +settings = get_settings() # ─── LLM Model Pool ──────────────────────────────────── @@ -133,7 +135,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, max_tokens_per_day=data.max_tokens_per_day, @@ -212,7 +214,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.max_tokens_per_day is not None: model.max_tokens_per_day = data.max_tokens_per_day if data.enabled is not None: diff --git a/backend/app/api/gateway.py b/backend/app/api/gateway.py index 8182562b..6962c17f 100644 --- a/backend/app/api/gateway.py +++ b/backend/app/api/gateway.py @@ -15,6 +15,8 @@ from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession +from app.core.security import get_current_user +from app.core.permissions import check_agent_access, is_agent_creator from app.database import get_db, async_session from app.models.agent import Agent from app.models.gateway_message import GatewayMessage @@ -77,15 +79,20 @@ async def generate_api_key( @router.post("/agents/{agent_id}/api-key") -async def generate_agent_api_key(agent_id: uuid.UUID, db: AsyncSession = Depends(get_db)): +async def generate_agent_api_key( + agent_id: uuid.UUID, + current_user: User = Depends(get_current_user), + 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") + agent, _access = await check_agent_access(db, current_user, agent_id) + if not is_agent_creator(current_user, agent) and current_user.role not in ("platform_admin", "org_admin"): + raise HTTPException(status_code=403, detail="Only creator or admin can manage API keys") + if getattr(agent, "agent_type", "native") != "openclaw": + raise HTTPException(status_code=400, detail="API keys are only available for OpenClaw agents") # Generate a new key raw_key = f"oc-{secrets.token_urlsafe(32)}" diff --git a/backend/app/api/plaza.py b/backend/app/api/plaza.py index 6762368d..b0afb65c 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 = ( diff --git a/backend/app/core/permissions.py b/backend/app/core/permissions.py index aada43aa..63e551ad 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 727f95ba..cbbd462e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -62,6 +62,13 @@ async def lifespan(app: FastAPI): intercept_standard_logging() logger.info("[startup] Logging configured") + # Reject default JWT secrets in production + if "change-me" in settings.SECRET_KEY or "change-me" in settings.JWT_SECRET_KEY: + if settings.DEBUG: + logger.warning("[startup] SECRET_KEY or JWT_SECRET_KEY contains default 'change-me' value — acceptable in DEBUG mode only") + else: + raise SystemExit("FATAL: SECRET_KEY or JWT_SECRET_KEY contains default 'change-me' value. Set secure secrets before running in production.") + import asyncio import sys import os diff --git a/backend/app/services/task_executor.py b/backend/app/services/task_executor.py index baa2bf87..711a95a3 100644 --- a/backend/app/services/task_executor.py +++ b/backend/app/services/task_executor.py @@ -12,11 +12,15 @@ from loguru import logger from sqlalchemy import select +from app.config import get_settings +from app.core.security import decrypt_data 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 +68,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: @@ -129,7 +133,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=decrypt_data(model.api_key_encrypted, settings.SECRET_KEY), model=model.model, base_url=model.base_url, timeout=1200.0,