Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions backend/app/api/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."}
Expand Down
8 changes: 5 additions & 3 deletions backend/app/api/enterprise.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -31,6 +32,7 @@
from app.services.sso_service import sso_service

router = APIRouter(prefix="/enterprise", tags=["enterprise"])
settings = get_settings()


# ─── LLM Model Pool ────────────────────────────────────
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
17 changes: 12 additions & 5 deletions backend/app/api/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)}"
Expand Down
39 changes: 28 additions & 11 deletions backend/app/api/plaza.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand All @@ -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 = (
Expand Down
4 changes: 4 additions & 0 deletions backend/app/core/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 7 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions backend/app/services/task_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down