Skip to content
Merged
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 @@ -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."}
Expand Down
14 changes: 8 additions & 6 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 @@ -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 ────────────────────────
Expand Down Expand Up @@ -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"}

Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
41 changes: 2 additions & 39 deletions backend/app/api/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
91 changes: 62 additions & 29 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 All @@ -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

Expand All @@ -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)
)
Expand All @@ -236,34 +256,40 @@ 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()
return {"deleted": True}


@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,
Expand Down Expand Up @@ -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)
)
Expand Down
4 changes: 2 additions & 2 deletions backend/app/api/websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
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 @@ -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
Expand Down
5 changes: 3 additions & 2 deletions backend/app/services/agent_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion backend/app/services/agent_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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),
Expand Down
Loading