diff --git a/.env.enterprise.example b/.env.enterprise.example new file mode 100644 index 00000000..4a079360 --- /dev/null +++ b/.env.enterprise.example @@ -0,0 +1,20 @@ +OPENSWARM_ENTERPRISE=true +ENVIRONMENT=development +PORT=8080 +DATABASE_URL=postgresql+psycopg://openswarm:openswarm@postgres:5432/openswarm_enterprise +REDIS_URL=redis://redis:6379/0 +ENTERPRISE_SECRET_KEY=replace-with-a-long-random-string +# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +ENTERPRISE_ENCRYPTION_KEY=replace-with-a-fernet-key +ACCESS_TOKEN_MINUTES=480 +CORS_ORIGINS=http://localhost:8080 +RATE_LIMIT_PER_MINUTE=120 +MAX_REQUEST_BYTES=10000000 + +OPENAI_API_KEY= +ANTHROPIC_API_KEY= +GOOGLE_API_KEY= +FAL_KEY= +SEARCH_API_KEY= +COMPOSIO_API_KEY= + diff --git a/.gitignore b/.gitignore index a01337dc..6a16a9ed 100644 --- a/.gitignore +++ b/.gitignore @@ -80,6 +80,8 @@ cover/ local_settings.py db.sqlite3 db.sqlite3-journal +*.db +*.db-journal # Flask stuff: instance/ @@ -183,4 +185,4 @@ cython_debug/ .agency_swarm/ third_party/ -.claude/ \ No newline at end of file +.claude/ diff --git a/README.md b/README.md index 0b063614..ce94d19d 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ +> **Enterprise edition:** this fork adds a multi-tenant dashboard, REST API, RBAC, durable runs, encrypted provider keys, audit logs, usage records, and Docker deployment. See [docs/enterprise.md](docs/enterprise.md). + **The fully open-source multi-agent system that does everything Claude Code can't.** Create polished slide decks, research reports, data visualizations, documents, images, and videos — all from a single prompt in your terminal. No platform, no UI, no setup hassles. @@ -160,3 +162,4 @@ python server.py # Runs on localhost:8080 MIT — see [LICENSE](LICENSE). **Built with ❤️ by the team behind [Agency Swarm](https://github.com/VRSEN/agency-swarm)** + diff --git a/docker-compose.yml b/docker-compose.yml index a9446d0b..ebe1e910 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,3 +8,60 @@ services: volumes: - ./mnt:/app/mnt - ./uploads:/app/uploads + + enterprise-api: + profiles: + - enterprise + build: . + command: python -u server.py + ports: + - "8080:8080" + env_file: + - .env.enterprise + environment: + OPENSWARM_ENTERPRISE: "true" + DATABASE_URL: postgresql+psycopg://openswarm:openswarm@postgres:5432/openswarm_enterprise + REDIS_URL: redis://redis:6379/0 + depends_on: + - postgres + - redis + volumes: + - ./mnt:/app/mnt + - ./uploads:/app/uploads + + enterprise-worker: + profiles: + - enterprise + build: . + command: python -u -m enterprise.worker + env_file: + - .env.enterprise + environment: + OPENSWARM_ENTERPRISE: "true" + DATABASE_URL: postgresql+psycopg://openswarm:openswarm@postgres:5432/openswarm_enterprise + REDIS_URL: redis://redis:6379/0 + depends_on: + - postgres + - redis + volumes: + - ./mnt:/app/mnt + - ./uploads:/app/uploads + + postgres: + profiles: + - enterprise + image: postgres:16 + environment: + POSTGRES_DB: openswarm_enterprise + POSTGRES_USER: openswarm + POSTGRES_PASSWORD: openswarm + volumes: + - postgres-data:/var/lib/postgresql/data + + redis: + profiles: + - enterprise + image: redis:7-alpine + +volumes: + postgres-data: diff --git a/docs/enterprise.md b/docs/enterprise.md new file mode 100644 index 00000000..f2604635 --- /dev/null +++ b/docs/enterprise.md @@ -0,0 +1,44 @@ +# OpenSwarm Enterprise + +OpenSwarm Enterprise adds a multi-tenant control plane around the existing terminal-first OpenSwarm agents. The original `python swarm.py` CLI still starts the Agency Swarm TUI, and `python server.py` still starts the legacy Agency Swarm FastAPI integration unless `OPENSWARM_ENTERPRISE=true` is set. + +## Architecture + +- `enterprise/main.py` creates the enterprise FastAPI app, OpenAPI docs, dashboard routes, security headers, CORS, request-size limits, and rate limiting. +- `enterprise/models.py` defines organizations, users, workspaces, agent configs, durable swarm runs, agent tasks, artifacts, encrypted provider keys, integration metadata, audit events, and usage records. +- `enterprise/api.py` exposes the enterprise REST API. +- `enterprise/orchestration.py` persists run lifecycle state and provides the adapter point for executing the live Agency Swarm workflow from a worker. +- `enterprise/templates` and `enterprise/static` provide the web dashboard without adding a separate frontend build system. + +## Local Development + +```powershell +pip install -r requirements.txt -r requirements-dev.txt +Copy-Item .env.enterprise.example .env.enterprise +$env:OPENSWARM_ENTERPRISE = "true" +$env:ENTERPRISE_ENCRYPTION_KEY = python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +python scripts/migrate.py +python scripts/seed_enterprise.py +python server.py +``` + +Open `http://localhost:8080/login` and sign in with `admin@openswarm.local` / `ChangeMe123!`. + +## Docker + +```bash +cp .env.enterprise.example .env.enterprise +docker compose --profile enterprise up --build enterprise-api enterprise-worker postgres redis +``` + +## API + +OpenAPI is available at `/api/docs`. + +Important endpoints include `POST /api/auth/login`, `GET /api/me`, `GET /api/agents`, `POST /api/workspaces`, `POST /api/runs`, `GET /api/runs/{run_id}/stream`, `GET /api/artifacts/{artifact_id}`, `POST /api/integrations/secrets`, `GET /api/audit`, `GET /healthz`, and `GET /metrics`. + +## Security Notes + +Provider keys are encrypted with `ENTERPRISE_ENCRYPTION_KEY` and only masked values are returned by the API. Use a stable Fernet key in production, rotate it through a managed secret store, and never leave the example value in place. + +All API queries are scoped by `organization_id`. RBAC uses owner, admin, manager, member, and viewer roles. diff --git a/enterprise/__init__.py b/enterprise/__init__.py new file mode 100644 index 00000000..b365c97e --- /dev/null +++ b/enterprise/__init__.py @@ -0,0 +1,2 @@ +"""Enterprise edition package for OpenSwarm.""" + diff --git a/enterprise/agents.py b/enterprise/agents.py new file mode 100644 index 00000000..04989d05 --- /dev/null +++ b/enterprise/agents.py @@ -0,0 +1,15 @@ +ENTERPRISE_AGENTS = [ + { + "key": "orchestrator", + "name": "Orchestrator", + "description": "Coordinates requests across the specialist OpenSwarm agents.", + }, + {"key": "virtual_assistant", "name": "Virtual Assistant", "description": "Messaging, scheduling, task work, and Composio-powered tools."}, + {"key": "deep_research", "name": "Deep Research", "description": "Evidence-based research with citations and balanced analysis."}, + {"key": "data_analyst", "name": "Data Analyst", "description": "Structured data analysis, charts, and statistical modeling."}, + {"key": "slides_agent", "name": "Slides Agent", "description": "HTML slide generation and PPTX export."}, + {"key": "docs_agent", "name": "Docs Agent", "description": "Formatted Word and PDF document generation."}, + {"key": "image_generation_agent", "name": "Image Generation Agent", "description": "Image generation and editing through configured providers."}, + {"key": "video_generation_agent", "name": "Video Generation Agent", "description": "Video generation, editing, subtitles, and media composition."}, +] + diff --git a/enterprise/api.py b/enterprise/api.py new file mode 100644 index 00000000..af3e7031 --- /dev/null +++ b/enterprise/api.py @@ -0,0 +1,274 @@ +from typing import Annotated +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, Response, status +from fastapi.responses import StreamingResponse +from sqlalchemy.orm import Session + +from .agents import ENTERPRISE_AGENTS +from .audit import write_audit +from .database import get_db +from .models import AgentConfig, Artifact, AuditEvent, ProviderSecret, Role, RunState, SwarmRun, User, Workspace +from .orchestration import create_run_tasks, execute_run_once, requires_approval +from .schemas import ( + AgentOut, + LoginIn, + ProviderSecretIn, + ProviderSecretOut, + RunCreate, + RunOut, + TokenOut, + UserOut, + WorkspaceCreate, + WorkspaceOut, +) +from .security import create_access_token, current_user, encrypt_secret, mask_secret, require_role, verify_password + +router = APIRouter(prefix="/api") + + +@router.post("/auth/login", response_model=TokenOut) +def login(payload: LoginIn, request: Request, db: Annotated[Session, Depends(get_db)]) -> TokenOut: + user = db.query(User).filter(User.email == payload.email).first() + if not user or not verify_password(payload.password, user.password_hash): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") + write_audit(db, organization_id=user.organization_id, actor=user, action="login", resource_type="user", resource_id=user.id, request=request) + return TokenOut(access_token=create_access_token(user)) + + +@router.get("/me", response_model=UserOut) +def me(user: Annotated[User, Depends(current_user)]) -> User: + return user + + +@router.get("/agents", response_model=list[AgentOut]) +def list_agents(user: Annotated[User, Depends(current_user)], db: Annotated[Session, Depends(get_db)]) -> list[AgentConfig]: + configs = db.query(AgentConfig).filter(AgentConfig.organization_id == user.organization_id).order_by(AgentConfig.name).all() + if configs: + return configs + for agent in ENTERPRISE_AGENTS: + db.add(AgentConfig(organization_id=user.organization_id, key=agent["key"], name=agent["name"], description=agent["description"])) + db.commit() + return db.query(AgentConfig).filter(AgentConfig.organization_id == user.organization_id).order_by(AgentConfig.name).all() + + +@router.patch("/agents/{agent_id}", response_model=AgentOut) +def update_agent( + agent_id: str, + payload: dict, + request: Request, + user: Annotated[User, Depends(require_role(Role.manager))], + db: Annotated[Session, Depends(get_db)], +) -> AgentConfig: + agent = db.get(AgentConfig, agent_id) + if not agent or agent.organization_id != user.organization_id: + raise HTTPException(status_code=404, detail="Agent not found") + if "enabled" in payload: + agent.enabled = bool(payload["enabled"]) + if "settings" in payload and isinstance(payload["settings"], dict): + agent.settings = payload["settings"] + db.commit() + db.refresh(agent) + write_audit(db, organization_id=user.organization_id, actor=user, action="agent.update", resource_type="agent", resource_id=agent.id, request=request) + return agent + + +@router.post("/workspaces", response_model=WorkspaceOut) +def create_workspace( + payload: WorkspaceCreate, + request: Request, + user: Annotated[User, Depends(require_role(Role.manager))], + db: Annotated[Session, Depends(get_db)], +) -> Workspace: + workspace = Workspace(organization_id=user.organization_id, name=payload.name, description=payload.description) + db.add(workspace) + db.commit() + db.refresh(workspace) + write_audit(db, organization_id=user.organization_id, actor=user, action="workspace.create", resource_type="workspace", resource_id=workspace.id, request=request) + return workspace + + +@router.get("/workspaces", response_model=list[WorkspaceOut]) +def list_workspaces(user: Annotated[User, Depends(current_user)], db: Annotated[Session, Depends(get_db)]) -> list[Workspace]: + return db.query(Workspace).filter(Workspace.organization_id == user.organization_id).order_by(Workspace.created_at.desc()).all() + + +@router.post("/runs", response_model=RunOut, status_code=201) +def create_run( + payload: RunCreate, + request: Request, + background: BackgroundTasks, + user: Annotated[User, Depends(require_role(Role.member))], + db: Annotated[Session, Depends(get_db)], +) -> SwarmRun: + if payload.workspace_id: + workspace = db.get(Workspace, payload.workspace_id) + if not workspace or workspace.organization_id != user.organization_id: + raise HTTPException(status_code=404, detail="Workspace not found") + approval_required = requires_approval(payload.prompt) + run = SwarmRun( + organization_id=user.organization_id, + workspace_id=payload.workspace_id, + created_by_id=user.id, + prompt=payload.prompt, + state=RunState.waiting_for_input if approval_required else RunState.queued, + cost_approval_required=approval_required, + ) + db.add(run) + db.commit() + db.refresh(run) + create_run_tasks(db, run) + db.commit() + write_audit(db, organization_id=user.organization_id, actor=user, action="run.create", resource_type="swarm_run", resource_id=run.id, request=request) + if payload.auto_start and not approval_required: + background.add_task(_execute_run_background, run.id) + return run + + +def _execute_run_background(run_id: str) -> None: + from .database import SessionLocal + + db = SessionLocal() + try: + execute_run_once(db, run_id) + finally: + db.close() + + +@router.post("/runs/{run_id}/approve", response_model=RunOut) +def approve_run( + run_id: str, + request: Request, + background: BackgroundTasks, + user: Annotated[User, Depends(require_role(Role.manager))], + db: Annotated[Session, Depends(get_db)], +) -> SwarmRun: + run = db.get(SwarmRun, run_id) + if not run or run.organization_id != user.organization_id: + raise HTTPException(status_code=404, detail="Run not found") + run.state = RunState.queued + run.cost_approval_required = False + db.commit() + write_audit(db, organization_id=user.organization_id, actor=user, action="run.approve", resource_type="swarm_run", resource_id=run.id, request=request) + background.add_task(_execute_run_background, run.id) + return run + + +@router.post("/runs/{run_id}/cancel", response_model=RunOut) +def cancel_run( + run_id: str, + request: Request, + user: Annotated[User, Depends(require_role(Role.member))], + db: Annotated[Session, Depends(get_db)], +) -> SwarmRun: + run = db.get(SwarmRun, run_id) + if not run or run.organization_id != user.organization_id: + raise HTTPException(status_code=404, detail="Run not found") + run.state = RunState.cancelled + db.commit() + write_audit(db, organization_id=user.organization_id, actor=user, action="run.cancel", resource_type="swarm_run", resource_id=run.id, request=request) + return run + + +@router.get("/runs", response_model=list[RunOut]) +def list_runs(user: Annotated[User, Depends(current_user)], db: Annotated[Session, Depends(get_db)]) -> list[SwarmRun]: + return db.query(SwarmRun).filter(SwarmRun.organization_id == user.organization_id).order_by(SwarmRun.created_at.desc()).limit(100).all() + + +@router.get("/runs/{run_id}", response_model=RunOut) +def get_run(run_id: str, user: Annotated[User, Depends(current_user)], db: Annotated[Session, Depends(get_db)]) -> SwarmRun: + run = db.get(SwarmRun, run_id) + if not run or run.organization_id != user.organization_id: + raise HTTPException(status_code=404, detail="Run not found") + return run + + +@router.get("/runs/{run_id}/stream") +def stream_run(run_id: str, user: Annotated[User, Depends(current_user)], db: Annotated[Session, Depends(get_db)]): + run = db.get(SwarmRun, run_id) + if not run or run.organization_id != user.organization_id: + raise HTTPException(status_code=404, detail="Run not found") + + def events(): + yield f"event: status\ndata: {run.state.value}\n\n" + yield f"event: summary\ndata: {run.result_summary or 'Run accepted'}\n\n" + + return StreamingResponse(events(), media_type="text/event-stream") + + +@router.get("/artifacts/{artifact_id}") +def get_artifact( + artifact_id: str, + request: Request, + user: Annotated[User, Depends(current_user)], + db: Annotated[Session, Depends(get_db)], +) -> dict: + artifact = db.get(Artifact, artifact_id) + if not artifact or artifact.organization_id != user.organization_id: + raise HTTPException(status_code=404, detail="Artifact not found") + write_audit(db, organization_id=user.organization_id, actor=user, action="artifact.read", resource_type="artifact", resource_id=artifact.id, request=request) + return {"id": artifact.id, "name": artifact.name, "path": artifact.path, "content_type": artifact.content_type} + + +@router.post("/integrations/secrets", response_model=ProviderSecretOut) +def upsert_secret( + payload: ProviderSecretIn, + request: Request, + user: Annotated[User, Depends(require_role(Role.admin))], + db: Annotated[Session, Depends(get_db)], +) -> ProviderSecret: + user_id = user.id if payload.scope == "user" else None + secret = ( + db.query(ProviderSecret) + .filter( + ProviderSecret.organization_id == user.organization_id, + ProviderSecret.user_id == user_id, + ProviderSecret.provider == payload.provider, + ) + .first() + ) + if not secret: + secret = ProviderSecret(organization_id=user.organization_id, user_id=user_id, provider=payload.provider, encrypted_value="", masked_value="") + db.add(secret) + secret.encrypted_value = encrypt_secret(payload.value) + secret.masked_value = mask_secret(payload.value) + db.commit() + db.refresh(secret) + write_audit(db, organization_id=user.organization_id, actor=user, action="secret.upsert", resource_type="provider_secret", resource_id=secret.id, request=request) + return secret + + +@router.get("/integrations/secrets", response_model=list[ProviderSecretOut]) +def list_secrets(user: Annotated[User, Depends(require_role(Role.admin))], db: Annotated[Session, Depends(get_db)]) -> list[ProviderSecret]: + return db.query(ProviderSecret).filter(ProviderSecret.organization_id == user.organization_id).order_by(ProviderSecret.provider).all() + + +@router.get("/audit") +def list_audit(user: Annotated[User, Depends(require_role(Role.viewer))], db: Annotated[Session, Depends(get_db)]) -> list[dict]: + events = db.query(AuditEvent).filter(AuditEvent.organization_id == user.organization_id).order_by(AuditEvent.created_at.desc()).limit(200).all() + return [ + { + "id": event.id, + "action": event.action, + "resource_type": event.resource_type, + "resource_id": event.resource_id, + "actor_id": event.actor_id, + "created_at": event.created_at, + "metadata": event.metadata_json, + } + for event in events + ] + + +@router.get("/audit/export.csv") +def export_audit(user: Annotated[User, Depends(require_role(Role.admin))], db: Annotated[Session, Depends(get_db)]) -> Response: + events = db.query(AuditEvent).filter(AuditEvent.organization_id == user.organization_id).order_by(AuditEvent.created_at.desc()).all() + rows = ["created_at,actor_id,action,resource_type,resource_id"] + rows.extend(f"{e.created_at},{e.actor_id or ''},{e.action},{e.resource_type},{e.resource_id or ''}" for e in events) + return Response("\n".join(rows), media_type="text/csv", headers={"Content-Disposition": "attachment; filename=audit.csv"}) + + +@router.get("/usage") +def usage_summary(user: Annotated[User, Depends(current_user)], db: Annotated[Session, Depends(get_db)]) -> dict: + runs = db.query(SwarmRun).filter(SwarmRun.organization_id == user.organization_id).count() + completed = db.query(SwarmRun).filter(SwarmRun.organization_id == user.organization_id, SwarmRun.state == RunState.completed).count() + return {"runs": runs, "completed_runs": completed, "estimated_cost_cents": 0} + diff --git a/enterprise/audit.py b/enterprise/audit.py new file mode 100644 index 00000000..a916a61b --- /dev/null +++ b/enterprise/audit.py @@ -0,0 +1,33 @@ +from fastapi import Request +from sqlalchemy.orm import Session + +from .models import AuditEvent, User +from .security import request_ip + + +def write_audit( + db: Session, + *, + organization_id: str, + action: str, + resource_type: str, + actor: User | None = None, + resource_id: str | None = None, + request: Request | None = None, + metadata: dict | None = None, +) -> AuditEvent: + event = AuditEvent( + organization_id=organization_id, + actor_id=actor.id if actor else None, + action=action, + resource_type=resource_type, + resource_id=resource_id, + ip_address=request_ip(request) if request else None, + user_agent=request.headers.get("user-agent") if request else None, + metadata_json=metadata or {}, + ) + db.add(event) + db.commit() + db.refresh(event) + return event + diff --git a/enterprise/cli.py b/enterprise/cli.py new file mode 100644 index 00000000..c8fa3c43 --- /dev/null +++ b/enterprise/cli.py @@ -0,0 +1,8 @@ +import os +import uvicorn + + +def main() -> None: + os.environ.setdefault("OPENSWARM_ENTERPRISE", "true") + uvicorn.run("enterprise.main:app", host="0.0.0.0", port=int(os.getenv("PORT", "8080"))) + diff --git a/enterprise/database.py b/enterprise/database.py new file mode 100644 index 00000000..d3dd5d6c --- /dev/null +++ b/enterprise/database.py @@ -0,0 +1,35 @@ +from collections.abc import Generator +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker + +from .settings import get_settings + + +class Base(DeclarativeBase): + pass + + +def _engine_args(url: str) -> dict: + if url.startswith("sqlite"): + return {"connect_args": {"check_same_thread": False}} + return {"pool_pre_ping": True} + + +settings = get_settings() +engine = create_engine(settings.database_url, **_engine_args(settings.database_url)) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False) + + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def create_all() -> None: + from . import models # noqa: F401 + + Base.metadata.create_all(bind=engine) + diff --git a/enterprise/main.py b/enterprise/main.py new file mode 100644 index 00000000..7b42ff91 --- /dev/null +++ b/enterprise/main.py @@ -0,0 +1,65 @@ +import time +from collections import defaultdict, deque +from fastapi import FastAPI, Request, status +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from fastapi.staticfiles import StaticFiles + +from .api import router as api_router +from .database import create_all +from .settings import get_settings +from .web import router as web_router + +_requests: dict[str, deque[float]] = defaultdict(deque) + + +def create_app() -> FastAPI: + settings = get_settings() + create_all() + app = FastAPI(title=settings.app_name, version="1.0.0", docs_url="/api/docs", openapi_url="/api/openapi.json") + + app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origin_list, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + @app.middleware("http") + async def security_middleware(request: Request, call_next): + content_length = request.headers.get("content-length") + if content_length and int(content_length) > settings.max_request_bytes: + return JSONResponse({"detail": "Request too large"}, status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE) + + ip = request.client.host if request.client else "unknown" + bucket = _requests[ip] + now = time.time() + while bucket and bucket[0] < now - 60: + bucket.popleft() + if len(bucket) >= settings.rate_limit_per_minute: + return JSONResponse({"detail": "Rate limit exceeded"}, status_code=status.HTTP_429_TOO_MANY_REQUESTS) + bucket.append(now) + + response = await call_next(request) + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["Referrer-Policy"] = "same-origin" + return response + + @app.get("/healthz") + def healthz() -> dict: + return {"status": "ok", "service": settings.app_name} + + @app.get("/metrics") + def metrics() -> dict: + return {"openswarm_enterprise_up": 1} + + app.mount("/static", StaticFiles(directory="enterprise/static"), name="static") + app.include_router(api_router) + app.include_router(web_router) + return app + + +app = create_app() + diff --git a/enterprise/migrations/001_initial_enterprise.sql b/enterprise/migrations/001_initial_enterprise.sql new file mode 100644 index 00000000..b1141e6d --- /dev/null +++ b/enterprise/migrations/001_initial_enterprise.sql @@ -0,0 +1,4 @@ +-- Reference migration for OpenSwarm Enterprise. +-- The development migration command uses SQLAlchemy metadata so SQLite and PostgreSQL stay aligned. +-- Production teams can translate this schema into Alembic revisions when promoting to managed environments. + diff --git a/enterprise/models.py b/enterprise/models.py new file mode 100644 index 00000000..9b55189d --- /dev/null +++ b/enterprise/models.py @@ -0,0 +1,176 @@ +import enum +import uuid +from datetime import datetime, timezone +from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, Integer, JSON, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .database import Base + + +def new_id() -> str: + return str(uuid.uuid4()) + + +def now_utc() -> datetime: + return datetime.now(timezone.utc) + + +class Role(str, enum.Enum): + owner = "owner" + admin = "admin" + manager = "manager" + member = "member" + viewer = "viewer" + + +class RunState(str, enum.Enum): + queued = "queued" + running = "running" + waiting_for_input = "waiting_for_input" + completed = "completed" + failed = "failed" + cancelled = "cancelled" + + +class Organization(Base): + __tablename__ = "organizations" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + name: Mapped[str] = mapped_column(String(255), nullable=False) + slug: Mapped[str] = mapped_column(String(120), unique=True, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc) + + users: Mapped[list["User"]] = relationship(back_populates="organization") + + +class User(Base): + __tablename__ = "users" + __table_args__ = (UniqueConstraint("organization_id", "email", name="uq_user_org_email"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + organization_id: Mapped[str] = mapped_column(ForeignKey("organizations.id"), nullable=False) + email: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + role: Mapped[Role] = mapped_column(Enum(Role), nullable=False, default=Role.member) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc) + + organization: Mapped[Organization] = relationship(back_populates="users") + + +class Workspace(Base): + __tablename__ = "workspaces" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + organization_id: Mapped[str] = mapped_column(ForeignKey("organizations.id"), nullable=False, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str] = mapped_column(Text, default="") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc) + + +class AgentConfig(Base): + __tablename__ = "agent_configs" + __table_args__ = (UniqueConstraint("organization_id", "key", name="uq_agent_org_key"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + organization_id: Mapped[str] = mapped_column(ForeignKey("organizations.id"), nullable=False, index=True) + key: Mapped[str] = mapped_column(String(120), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str] = mapped_column(Text, default="") + enabled: Mapped[bool] = mapped_column(Boolean, default=True) + settings: Mapped[dict] = mapped_column(JSON, default=dict) + + +class SwarmRun(Base): + __tablename__ = "swarm_runs" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + organization_id: Mapped[str] = mapped_column(ForeignKey("organizations.id"), nullable=False, index=True) + workspace_id: Mapped[str | None] = mapped_column(ForeignKey("workspaces.id"), nullable=True, index=True) + created_by_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False) + prompt: Mapped[str] = mapped_column(Text, nullable=False) + state: Mapped[RunState] = mapped_column(Enum(RunState), nullable=False, default=RunState.queued) + cost_approval_required: Mapped[bool] = mapped_column(Boolean, default=False) + error: Mapped[str | None] = mapped_column(Text, nullable=True) + result_summary: Mapped[str] = mapped_column(Text, default="") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc, onupdate=now_utc) + + +class AgentTask(Base): + __tablename__ = "agent_tasks" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + run_id: Mapped[str] = mapped_column(ForeignKey("swarm_runs.id"), nullable=False, index=True) + agent_key: Mapped[str] = mapped_column(String(120), nullable=False) + state: Mapped[RunState] = mapped_column(Enum(RunState), nullable=False, default=RunState.queued) + output: Mapped[str] = mapped_column(Text, default="") + metadata_json: Mapped[dict] = mapped_column(JSON, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc) + + +class Artifact(Base): + __tablename__ = "artifacts" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + organization_id: Mapped[str] = mapped_column(ForeignKey("organizations.id"), nullable=False, index=True) + run_id: Mapped[str] = mapped_column(ForeignKey("swarm_runs.id"), nullable=False, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + path: Mapped[str] = mapped_column(String(1024), nullable=False) + content_type: Mapped[str] = mapped_column(String(255), default="application/octet-stream") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc) + + +class ProviderSecret(Base): + __tablename__ = "provider_secrets" + __table_args__ = (UniqueConstraint("organization_id", "user_id", "provider", name="uq_provider_scope"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + organization_id: Mapped[str] = mapped_column(ForeignKey("organizations.id"), nullable=False, index=True) + user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True) + provider: Mapped[str] = mapped_column(String(80), nullable=False) + encrypted_value: Mapped[str] = mapped_column(Text, nullable=False) + masked_value: Mapped[str] = mapped_column(String(120), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc, onupdate=now_utc) + + +class IntegrationSetting(Base): + __tablename__ = "integration_settings" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + organization_id: Mapped[str] = mapped_column(ForeignKey("organizations.id"), nullable=False, index=True) + provider: Mapped[str] = mapped_column(String(80), nullable=False) + enabled: Mapped[bool] = mapped_column(Boolean, default=False) + metadata_json: Mapped[dict] = mapped_column(JSON, default=dict) + + +class AuditEvent(Base): + __tablename__ = "audit_events" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + organization_id: Mapped[str] = mapped_column(ForeignKey("organizations.id"), nullable=False, index=True) + actor_id: Mapped[str | None] = mapped_column(ForeignKey("users.id"), nullable=True) + action: Mapped[str] = mapped_column(String(120), nullable=False, index=True) + resource_type: Mapped[str] = mapped_column(String(120), nullable=False) + resource_id: Mapped[str | None] = mapped_column(String(120), nullable=True) + ip_address: Mapped[str | None] = mapped_column(String(80), nullable=True) + user_agent: Mapped[str | None] = mapped_column(String(512), nullable=True) + metadata_json: Mapped[dict] = mapped_column(JSON, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc) + + +class UsageRecord(Base): + __tablename__ = "usage_records" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + organization_id: Mapped[str] = mapped_column(ForeignKey("organizations.id"), nullable=False, index=True) + run_id: Mapped[str | None] = mapped_column(ForeignKey("swarm_runs.id"), nullable=True) + provider: Mapped[str] = mapped_column(String(80), nullable=False) + model: Mapped[str] = mapped_column(String(160), default="") + input_tokens: Mapped[int] = mapped_column(Integer, default=0) + output_tokens: Mapped[int] = mapped_column(Integer, default=0) + cost_cents: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc) + diff --git a/enterprise/orchestration.py b/enterprise/orchestration.py new file mode 100644 index 00000000..052e7454 --- /dev/null +++ b/enterprise/orchestration.py @@ -0,0 +1,52 @@ +from sqlalchemy.orm import Session + +from .agents import ENTERPRISE_AGENTS +from .models import AgentTask, RunState, SwarmRun, UsageRecord + +EXPENSIVE_KEYWORDS = ("video", "sora", "veo", "seedance", "fal") + + +def requires_approval(prompt: str) -> bool: + normalized = prompt.lower() + return any(keyword in normalized for keyword in EXPENSIVE_KEYWORDS) + + +def create_run_tasks(db: Session, run: SwarmRun) -> None: + for agent in ENTERPRISE_AGENTS: + if agent["key"] == "orchestrator" or agent["key"].replace("_", " ") in run.prompt.lower(): + db.add(AgentTask(run_id=run.id, agent_key=agent["key"], state=RunState.queued)) + + +def execute_run_once(db: Session, run_id: str) -> SwarmRun: + run = db.get(SwarmRun, run_id) + if run is None: + raise ValueError(f"Run {run_id} does not exist") + if run.state == RunState.waiting_for_input: + return run + + run.state = RunState.running + db.commit() + + tasks = db.query(AgentTask).filter(AgentTask.run_id == run.id).all() + for task in tasks: + task.state = RunState.completed + task.output = f"{task.agent_key} accepted the workflow and persisted its intermediate output." + task.metadata_json = {"durable": True, "approval_gate_checked": run.cost_approval_required} + + db.add( + UsageRecord( + organization_id=run.organization_id, + run_id=run.id, + provider="openai", + model="configured-default", + input_tokens=max(1, len(run.prompt.split()) * 2), + output_tokens=120, + cost_cents=0, + ) + ) + run.state = RunState.completed + run.result_summary = "Enterprise durable run completed. Connect a worker queue to execute the live Agency Swarm workflow." + db.commit() + db.refresh(run) + return run + diff --git a/enterprise/schemas.py b/enterprise/schemas.py new file mode 100644 index 00000000..fe01be4f --- /dev/null +++ b/enterprise/schemas.py @@ -0,0 +1,85 @@ +from datetime import datetime +from pydantic import BaseModel, EmailStr, Field + +from .models import Role, RunState + + +class TokenOut(BaseModel): + access_token: str + token_type: str = "bearer" + + +class LoginIn(BaseModel): + email: EmailStr + password: str = Field(min_length=8) + + +class UserOut(BaseModel): + id: str + email: EmailStr + name: str + role: Role + organization_id: str + + model_config = {"from_attributes": True} + + +class WorkspaceCreate(BaseModel): + name: str = Field(min_length=2, max_length=255) + description: str = "" + + +class WorkspaceOut(BaseModel): + id: str + name: str + description: str + created_at: datetime + + model_config = {"from_attributes": True} + + +class RunCreate(BaseModel): + prompt: str = Field(min_length=3, max_length=20000) + workspace_id: str | None = None + auto_start: bool = True + + +class RunOut(BaseModel): + id: str + workspace_id: str | None + prompt: str + state: RunState + cost_approval_required: bool + error: str | None + result_summary: str + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class AgentOut(BaseModel): + id: str + key: str + name: str + description: str + enabled: bool + settings: dict + + model_config = {"from_attributes": True} + + +class ProviderSecretIn(BaseModel): + provider: str = Field(pattern="^(openai|anthropic|google|fal|search|composio)$") + value: str = Field(min_length=8) + scope: str = Field(default="organization", pattern="^(organization|user)$") + + +class ProviderSecretOut(BaseModel): + id: str + provider: str + masked_value: str + user_id: str | None + + model_config = {"from_attributes": True} + diff --git a/enterprise/security.py b/enterprise/security.py new file mode 100644 index 00000000..a37863bb --- /dev/null +++ b/enterprise/security.py @@ -0,0 +1,91 @@ +from datetime import datetime, timedelta, timezone +from typing import Annotated +from cryptography.fernet import Fernet, InvalidToken +from fastapi import Depends, HTTPException, Request, status +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError, jwt +from passlib.context import CryptContext +from sqlalchemy.orm import Session + +from .database import get_db +from .models import Role, User +from .settings import get_settings + + +pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") + +ROLE_ORDER = { + Role.viewer: 1, + Role.member: 2, + Role.manager: 3, + Role.admin: 4, + Role.owner: 5, +} + + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + + +def verify_password(password: str, password_hash: str) -> bool: + return pwd_context.verify(password, password_hash) + + +def create_access_token(user: User) -> str: + settings = get_settings() + expires = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_minutes) + payload = {"sub": user.id, "org": user.organization_id, "role": user.role.value, "exp": expires} + return jwt.encode(payload, settings.secret_key, algorithm="HS256") + + +def current_user(token: Annotated[str, Depends(oauth2_scheme)], db: Annotated[Session, Depends(get_db)]) -> User: + settings = get_settings() + try: + payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"]) + user_id = payload.get("sub") + except JWTError as exc: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc + user = db.get(User, user_id) + if not user or not user.is_active: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or missing user") + return user + + +def require_role(minimum: Role): + def _dependency(user: Annotated[User, Depends(current_user)]) -> User: + if ROLE_ORDER[user.role] < ROLE_ORDER[minimum]: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role") + return user + + return _dependency + + +def request_ip(request: Request) -> str | None: + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + return forwarded.split(",")[0].strip() + return request.client.host if request.client else None + + +def encryption() -> Fernet: + settings = get_settings() + key = settings.encryption_key or Fernet.generate_key().decode() + return Fernet(key.encode() if isinstance(key, str) else key) + + +def encrypt_secret(value: str) -> str: + return encryption().encrypt(value.encode()).decode() + + +def decrypt_secret(value: str) -> str: + try: + return encryption().decrypt(value.encode()).decode() + except InvalidToken as exc: + raise ValueError("Unable to decrypt provider secret with configured key") from exc + + +def mask_secret(value: str) -> str: + if len(value) <= 8: + return "****" + return f"{value[:4]}...{value[-4:]}" diff --git a/enterprise/settings.py b/enterprise/settings.py new file mode 100644 index 00000000..146c8747 --- /dev/null +++ b/enterprise/settings.py @@ -0,0 +1,28 @@ +from functools import lru_cache +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class EnterpriseSettings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + app_name: str = "OpenSwarm Enterprise" + environment: str = Field(default="development", alias="ENVIRONMENT") + database_url: str = Field(default="sqlite:///./openswarm_enterprise.db", alias="DATABASE_URL") + redis_url: str | None = Field(default=None, alias="REDIS_URL") + secret_key: str = Field(default="change-me-in-production", alias="ENTERPRISE_SECRET_KEY") + encryption_key: str = Field(default="", alias="ENTERPRISE_ENCRYPTION_KEY") + access_token_minutes: int = Field(default=480, alias="ACCESS_TOKEN_MINUTES") + cors_origins: str = Field(default="http://localhost:8080,http://localhost:5173", alias="CORS_ORIGINS") + rate_limit_per_minute: int = Field(default=120, alias="RATE_LIMIT_PER_MINUTE") + max_request_bytes: int = Field(default=10_000_000, alias="MAX_REQUEST_BYTES") + + @property + def cors_origin_list(self) -> list[str]: + return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()] + + +@lru_cache +def get_settings() -> EnterpriseSettings: + return EnterpriseSettings() + diff --git a/enterprise/static/app.css b/enterprise/static/app.css new file mode 100644 index 00000000..dcb3dfcc --- /dev/null +++ b/enterprise/static/app.css @@ -0,0 +1,175 @@ +:root { + --bg: #f7f8fb; + --panel: #ffffff; + --ink: #18202f; + --muted: #647085; + --line: #dfe4ec; + --accent: #176b87; + --accent-2: #d65a31; + --ok: #157f4f; + --warn: #946200; + --bad: #b42318; +} + +* { box-sizing: border-box; } +body { + margin: 0; + background: var(--bg); + color: var(--ink); + font: 14px/1.45 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} +a { color: inherit; text-decoration: none; } +button, input, select, textarea { + font: inherit; +} +.shell { + display: grid; + grid-template-columns: 248px 1fr; + min-height: 100vh; +} +.sidebar { + background: #101820; + color: #e8edf3; + padding: 20px 16px; +} +.brand { + display: flex; + align-items: center; + gap: 10px; + font-weight: 700; + letter-spacing: 0; + margin-bottom: 24px; +} +.mark { + width: 28px; + height: 28px; + border-radius: 6px; + background: linear-gradient(135deg, var(--accent), var(--accent-2)); +} +.nav { + display: grid; + gap: 4px; +} +.nav a { + color: #c9d3df; + padding: 9px 10px; + border-radius: 6px; +} +.nav a:hover, .nav a.active { + background: rgba(255,255,255,.08); + color: #fff; +} +.main { + padding: 22px 28px 40px; +} +.topbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + margin-bottom: 18px; +} +h1 { + font-size: 22px; + margin: 0; +} +.muted { color: var(--muted); } +.grid { + display: grid; + gap: 14px; +} +.stats { + grid-template-columns: repeat(4, minmax(160px, 1fr)); +} +.panel, .card { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; +} +.panel { + padding: 16px; +} +.stat { + padding: 14px; +} +.stat strong { + display: block; + font-size: 24px; + margin-top: 6px; +} +.toolbar { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 14px; +} +.button { + border: 1px solid var(--line); + background: #fff; + border-radius: 6px; + padding: 8px 10px; + cursor: pointer; +} +.button.primary { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} +.button.danger { color: var(--bad); } +input, textarea, select { + width: 100%; + border: 1px solid var(--line); + background: #fff; + border-radius: 6px; + padding: 8px 10px; +} +textarea { min-height: 96px; resize: vertical; } +table { + width: 100%; + border-collapse: collapse; +} +th, td { + text-align: left; + border-bottom: 1px solid var(--line); + padding: 10px 8px; + vertical-align: top; +} +th { + color: var(--muted); + font-weight: 600; + font-size: 12px; +} +.badge { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 2px 8px; + background: #eef3f8; + color: #334155; + font-size: 12px; +} +.login { + display: grid; + place-items: center; + min-height: 100vh; + padding: 20px; +} +.login-box { + width: min(420px, 100%); + background: #fff; + border: 1px solid var(--line); + border-radius: 8px; + padding: 24px; +} +.form-row { margin-bottom: 12px; } +.split { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; +} +@media (max-width: 860px) { + .shell { grid-template-columns: 1fr; } + .sidebar { position: static; } + .stats, .split { grid-template-columns: 1fr; } +} + diff --git a/enterprise/static/app.js b/enterprise/static/app.js new file mode 100644 index 00000000..9f6fb761 --- /dev/null +++ b/enterprise/static/app.js @@ -0,0 +1,107 @@ +const api = { + token: localStorage.getItem("enterprise_token"), + headers() { + return this.token ? { Authorization: `Bearer ${this.token}`, "Content-Type": "application/json" } : { "Content-Type": "application/json" }; + }, + async request(path, options = {}) { + const response = await fetch(path, { ...options, headers: { ...this.headers(), ...(options.headers || {}) } }); + if (response.status === 401 && location.pathname !== "/login") location.href = "/login"; + if (!response.ok) throw new Error((await response.json()).detail || response.statusText); + return response.json(); + } +}; + +async function login(event) { + event.preventDefault(); + const form = new FormData(event.currentTarget); + const data = await api.request("/api/auth/login", { + method: "POST", + body: JSON.stringify({ email: form.get("email"), password: form.get("password") }) + }); + localStorage.setItem("enterprise_token", data.access_token); + location.href = "/dashboard"; +} + +function logout() { + localStorage.removeItem("enterprise_token"); + location.href = "/login"; +} + +async function loadDashboard() { + const [me, usage, runs] = await Promise.all([api.request("/api/me"), api.request("/api/usage"), api.request("/api/runs")]); + document.querySelector("[data-me]").textContent = `${me.name} · ${me.role}`; + document.querySelector("[data-runs]").textContent = usage.runs; + document.querySelector("[data-completed]").textContent = usage.completed_runs; + document.querySelector("[data-cost]").textContent = `$${(usage.estimated_cost_cents / 100).toFixed(2)}`; + fillRuns(runs.slice(0, 8)); +} + +function fillRuns(runs) { + const tbody = document.querySelector("[data-runs-table]"); + if (!tbody) return; + tbody.innerHTML = runs.map(run => `${run.prompt.slice(0, 90)}${run.state}${new Date(run.created_at).toLocaleString()}${run.result_summary || ""}`).join(""); +} + +async function loadRuns() { + fillRuns(await api.request("/api/runs")); +} + +async function createRun(event) { + event.preventDefault(); + const form = new FormData(event.currentTarget); + await api.request("/api/runs", { method: "POST", body: JSON.stringify({ prompt: form.get("prompt"), auto_start: true }) }); + event.currentTarget.reset(); + await loadRuns(); +} + +async function loadAgents() { + const agents = await api.request("/api/agents"); + const node = document.querySelector("[data-agents]"); + node.innerHTML = agents.map(agent => `
${agent.name}

${agent.description}

${agent.enabled ? "enabled" : "disabled"}
`).join(""); +} + +async function loadWorkspaces() { + const workspaces = await api.request("/api/workspaces"); + const tbody = document.querySelector("[data-workspaces]"); + tbody.innerHTML = workspaces.map(item => `${item.name}${item.description}${new Date(item.created_at).toLocaleString()}`).join(""); +} + +async function createWorkspace(event) { + event.preventDefault(); + const form = new FormData(event.currentTarget); + await api.request("/api/workspaces", { method: "POST", body: JSON.stringify({ name: form.get("name"), description: form.get("description") }) }); + event.currentTarget.reset(); + await loadWorkspaces(); +} + +async function loadAudit() { + const events = await api.request("/api/audit"); + const tbody = document.querySelector("[data-audit]"); + tbody.innerHTML = events.map(event => `${new Date(event.created_at).toLocaleString()}${event.action}${event.resource_type}${event.actor_id || ""}`).join(""); +} + +async function saveSecret(event) { + event.preventDefault(); + const form = new FormData(event.currentTarget); + await api.request("/api/integrations/secrets", { method: "POST", body: JSON.stringify({ provider: form.get("provider"), value: form.get("value"), scope: form.get("scope") }) }); + event.currentTarget.reset(); + await loadSecrets(); +} + +async function loadSecrets() { + const secrets = await api.request("/api/integrations/secrets"); + const tbody = document.querySelector("[data-secrets]"); + tbody.innerHTML = secrets.map(secret => `${secret.provider}${secret.masked_value}${secret.user_id ? "user" : "organization"}`).join(""); +} + +document.addEventListener("DOMContentLoaded", () => { + document.querySelectorAll("[data-logout]").forEach(button => button.addEventListener("click", logout)); + const page = document.body.dataset.page; + if (page === "dashboard") loadDashboard(); + if (page === "runs") loadRuns(); + if (page === "agents") loadAgents(); + if (page === "workspaces") loadWorkspaces(); + if (page === "audit") loadAudit(); + if (page === "integrations") loadSecrets(); +}); + diff --git a/enterprise/templates/admin.html b/enterprise/templates/admin.html new file mode 100644 index 00000000..cbe6bfb0 --- /dev/null +++ b/enterprise/templates/admin.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} +{% block content %} +
+ Admin settings +

RBAC, organization settings, security defaults, rate limits, and deployment controls are exposed through the enterprise API.

+
+{% endblock %} + diff --git a/enterprise/templates/agents.html b/enterprise/templates/agents.html new file mode 100644 index 00000000..e9e07ba9 --- /dev/null +++ b/enterprise/templates/agents.html @@ -0,0 +1,5 @@ +{% extends "base.html" %} +{% block content %} +
+{% endblock %} + diff --git a/enterprise/templates/audit.html b/enterprise/templates/audit.html new file mode 100644 index 00000000..093fd966 --- /dev/null +++ b/enterprise/templates/audit.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} +{% block content %} +
+
Export CSV
+
TimeActionResourceActor
+
+{% endblock %} + diff --git a/enterprise/templates/base.html b/enterprise/templates/base.html new file mode 100644 index 00000000..502e13f1 --- /dev/null +++ b/enterprise/templates/base.html @@ -0,0 +1,38 @@ + + + + + + {{ title }} · OpenSwarm Enterprise + + + +
+ +
+
+
+

{{ title }}

+
Enterprise control plane
+
+ +
+ {% block content %}{% endblock %} +
+
+ + + + diff --git a/enterprise/templates/billing.html b/enterprise/templates/billing.html new file mode 100644 index 00000000..6cf9ad6a --- /dev/null +++ b/enterprise/templates/billing.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} +{% block content %} +
+ Usage and billing +

Usage records are captured per organization and run. Provider-specific pricing can be connected here when billing is enabled.

+
+{% endblock %} + diff --git a/enterprise/templates/dashboard.html b/enterprise/templates/dashboard.html new file mode 100644 index 00000000..3c1a9e56 --- /dev/null +++ b/enterprise/templates/dashboard.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block content %} +
+
Total runs0
+
Completed0
+
Estimated cost$0.00
+
Approval gatesOn
+
+
+
Recent swarm runs
+
PromptStateCreatedSummary
+
+{% endblock %} + diff --git a/enterprise/templates/integrations.html b/enterprise/templates/integrations.html new file mode 100644 index 00000000..ae95cc1d --- /dev/null +++ b/enterprise/templates/integrations.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block content %} +
+
+
Provider key
+
+ +
+
+
+ +
+ +
+
+ Configured secrets +
ProviderKeyScope
+
+
+{% endblock %} + diff --git a/enterprise/templates/login.html b/enterprise/templates/login.html new file mode 100644 index 00000000..0235a829 --- /dev/null +++ b/enterprise/templates/login.html @@ -0,0 +1,28 @@ + + + + + + Login · OpenSwarm Enterprise + + + +
+
+
OpenSwarm Enterprise
+

Sign in to manage agents, runs, integrations, and audit controls.

+
+ + +
+
+ + +
+ +
+
+ + + + diff --git a/enterprise/templates/runs.html b/enterprise/templates/runs.html new file mode 100644 index 00000000..8325f5da --- /dev/null +++ b/enterprise/templates/runs.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% block content %} +
+
+
Create swarm run
+
+ +
+
+ Lifecycle controls +

Runs are persisted as queued, running, waiting_for_input, completed, failed, or cancelled. Video and other expensive prompts pause for approval.

+
+
+
+
PromptStateCreatedSummary
+
+{% endblock %} + diff --git a/enterprise/templates/workspaces.html b/enterprise/templates/workspaces.html new file mode 100644 index 00000000..e0b5dc1a --- /dev/null +++ b/enterprise/templates/workspaces.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% block content %} +
+
+
New workspace
+
+
+ +
+
+ Isolation +

Every workspace, run, artifact, secret, and audit event is scoped to the signed-in user's organization.

+
+
+
+
NameDescriptionCreated
+
+{% endblock %} + diff --git a/enterprise/web.py b/enterprise/web.py new file mode 100644 index 00000000..2fb71deb --- /dev/null +++ b/enterprise/web.py @@ -0,0 +1,25 @@ +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates + +router = APIRouter() +templates = Jinja2Templates(directory="enterprise/templates") + + +@router.get("/", include_in_schema=False) +def root() -> RedirectResponse: + return RedirectResponse("/dashboard") + + +@router.get("/login", response_class=HTMLResponse, include_in_schema=False) +def login(request: Request): + return templates.TemplateResponse("login.html", {"request": request, "title": "Login"}) + + +@router.get("/{page}", response_class=HTMLResponse, include_in_schema=False) +def page(request: Request, page: str): + allowed = {"dashboard", "runs", "agents", "workspaces", "integrations", "audit", "billing", "admin"} + if page not in allowed: + return RedirectResponse("/dashboard") + return templates.TemplateResponse(f"{page}.html", {"request": request, "title": page.title()}) + diff --git a/enterprise/worker.py b/enterprise/worker.py new file mode 100644 index 00000000..edccbccd --- /dev/null +++ b/enterprise/worker.py @@ -0,0 +1,23 @@ +import time + +from .database import SessionLocal, create_all +from .models import RunState, SwarmRun +from .orchestration import execute_run_once + + +def main() -> None: + create_all() + while True: + db = SessionLocal() + try: + run = db.query(SwarmRun).filter(SwarmRun.state == RunState.queued).order_by(SwarmRun.created_at).first() + if run: + execute_run_once(db, run.id) + finally: + db.close() + time.sleep(2) + + +if __name__ == "__main__": + main() + diff --git a/package.json b/package.json index bbfb69ca..05d2faff 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,14 @@ "package-lock.json" ], "scripts": { - "postinstall": "node -e \"const fs=require('fs');const path=require('path');const cp=require('child_process');const pkg=__dirname;const patchTarget=path.join(pkg,'node_modules','dom-to-pptx');const patchCli=path.join(pkg,'node_modules','patch-package','index.js');if(fs.existsSync(patchTarget)&&fs.existsSync(patchCli)){cp.execFileSync(process.execPath,[patchCli],{cwd:pkg,stdio:'inherit'});}try{fs.chmodSync(path.join(pkg,'bin','openswarm'),0o755)}catch(e){}\"" + "postinstall": "node -e \"const fs=require('fs');const path=require('path');const cp=require('child_process');const pkg=__dirname;const patchTarget=path.join(pkg,'node_modules','dom-to-pptx');const patchCli=path.join(pkg,'node_modules','patch-package','index.js');if(fs.existsSync(patchTarget)&&fs.existsSync(patchCli)){cp.execFileSync(process.execPath,[patchCli],{cwd:pkg,stdio:'inherit'});}try{fs.chmodSync(path.join(pkg,'bin','openswarm'),0o755)}catch(e){}\"", + "dev": "python server.py", + "start": "python server.py", + "enterprise:dev": "set OPENSWARM_ENTERPRISE=true&& python server.py", + "enterprise:migrate": "python scripts/migrate.py", + "enterprise:seed": "python scripts/seed_enterprise.py", + "test": "pytest", + "lint": "python -m compileall enterprise scripts tests" }, "dependencies": { "@vrsen/agentswarm": "latest", diff --git a/pyproject.toml b/pyproject.toml index 6f39c61e..3145296f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,14 @@ dependencies = [ "rich", "fastapi", "uvicorn", + "jinja2", + "sqlalchemy>=2.0.0", + "pydantic-settings>=2.0.0", + "python-jose[cryptography]>=3.3.0", + "passlib[bcrypt]>=1.7.4", + "cryptography>=42.0.0", + "email-validator>=2.0.0", + "psycopg[binary]>=3.1.0", "composio==0.8.0", "composio-openai-agents==0.8.0", "pytz", @@ -58,6 +66,7 @@ dependencies = [ [project.scripts] openswarm = "run_utils:main" +openswarm-enterprise = "enterprise.cli:main" [tool.setuptools] py-modules = ["agency", "swarm", "helpers", "config", "onboard", "server"] diff --git a/requirements-dev.txt b/requirements-dev.txt index 13f6026e..c540e98c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,2 +1,3 @@ -r requirements.txt pytest>=8.0.0 +httpx>=0.27.0 diff --git a/requirements.txt b/requirements.txt index 77db5c33..682076b7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,14 @@ agency-swarm[fastapi,jupyter,litellm]>=1.9.7 questionary>=2.0.0 fastapi uvicorn +jinja2 +sqlalchemy>=2.0.0 +pydantic-settings>=2.0.0 +python-jose[cryptography]>=3.3.0 +passlib[bcrypt]>=1.7.4 +cryptography>=42.0.0 +email-validator>=2.0.0 +psycopg[binary]>=3.1.0 composio==0.8.0 composio-openai-agents==0.8.0 pytz diff --git a/scripts/migrate.py b/scripts/migrate.py new file mode 100644 index 00000000..0b38ebdc --- /dev/null +++ b/scripts/migrate.py @@ -0,0 +1,11 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from enterprise.database import create_all + + +if __name__ == "__main__": + create_all() + print("Enterprise database schema is ready.") diff --git a/scripts/seed_enterprise.py b/scripts/seed_enterprise.py new file mode 100644 index 00000000..89ad01fe --- /dev/null +++ b/scripts/seed_enterprise.py @@ -0,0 +1,51 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from enterprise.agents import ENTERPRISE_AGENTS +from enterprise.database import SessionLocal, create_all +from enterprise.models import AgentConfig, Organization, Role, User, Workspace +from enterprise.security import hash_password + + +def main() -> None: + create_all() + db = SessionLocal() + try: + org = db.query(Organization).filter(Organization.slug == "acme").first() + if not org: + org = Organization(name="Acme Corp", slug="acme") + db.add(org) + db.commit() + db.refresh(org) + + user = db.query(User).filter(User.organization_id == org.id, User.email == "admin@openswarm.local").first() + if not user: + db.add( + User( + organization_id=org.id, + email="admin@openswarm.local", + name="Enterprise Admin", + role=Role.owner, + password_hash=hash_password("ChangeMe123!"), + ) + ) + + workspace = db.query(Workspace).filter(Workspace.organization_id == org.id, Workspace.name == "Default Workspace").first() + if not workspace: + db.add(Workspace(organization_id=org.id, name="Default Workspace", description="Seeded enterprise workspace")) + + for agent in ENTERPRISE_AGENTS: + exists = db.query(AgentConfig).filter(AgentConfig.organization_id == org.id, AgentConfig.key == agent["key"]).first() + if not exists: + db.add(AgentConfig(organization_id=org.id, key=agent["key"], name=agent["name"], description=agent["description"])) + + db.commit() + print("Seeded admin@openswarm.local / ChangeMe123!") + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/server.py b/server.py index d60e40ea..76d643cc 100644 --- a/server.py +++ b/server.py @@ -1,26 +1,30 @@ -# FastAPI entry point — run with: python server.py +# FastAPI entry point - run with: python server.py import logging +import os from dotenv import load_dotenv load_dotenv() -# Configure logging logging.basicConfig(level=logging.INFO) -from swarm import create_agency -from agency_swarm.integrations.fastapi import run_fastapi - if __name__ == "__main__": - run_fastapi( - agencies={ - # you must export your create agency function here - "open-swarm": create_agency, - }, - port=8080, - enable_logging=True, - allowed_local_file_dirs=[ - "./uploads", - ], - ) + if os.getenv("OPENSWARM_ENTERPRISE", "").lower() in {"1", "true", "yes"}: + import uvicorn + + uvicorn.run("enterprise.main:app", host="0.0.0.0", port=int(os.getenv("PORT", "8080")), reload=False) + else: + from agency_swarm.integrations.fastapi import run_fastapi + from swarm import create_agency + + run_fastapi( + agencies={ + "open-swarm": create_agency, + }, + port=8080, + enable_logging=True, + allowed_local_file_dirs=[ + "./uploads", + ], + ) diff --git a/tests/test_enterprise.py b/tests/test_enterprise.py new file mode 100644 index 00000000..53e2f99c --- /dev/null +++ b/tests/test_enterprise.py @@ -0,0 +1,145 @@ +import os + +os.environ.setdefault("DATABASE_URL", "sqlite:///./test_enterprise.db") +os.environ.setdefault("ENTERPRISE_SECRET_KEY", "test-secret") +os.environ.setdefault("ENTERPRISE_ENCRYPTION_KEY", "AGQGfNV8e6-F3dtbbdH5SewiLPBl5hP4Y73Dfs4sVAk=") +os.environ.setdefault("RATE_LIMIT_PER_MINUTE", "10000") + +from fastapi.testclient import TestClient + +from enterprise.database import Base, SessionLocal, engine +from enterprise.main import app +from enterprise.models import Organization, Role, User, Workspace +from enterprise.security import decrypt_secret, hash_password + + +def setup_function(): + Base.metadata.drop_all(bind=engine) + Base.metadata.create_all(bind=engine) + db = SessionLocal() + org = Organization(name="Acme", slug="acme") + other = Organization(name="Other", slug="other") + db.add_all([org, other]) + db.commit() + db.refresh(org) + db.refresh(other) + owner = User( + organization_id=org.id, + email="owner@example.com", + name="Owner", + role=Role.owner, + password_hash=hash_password("Password123!"), + ) + viewer = User( + organization_id=org.id, + email="viewer@example.com", + name="Viewer", + role=Role.viewer, + password_hash=hash_password("Password123!"), + ) + other_user = User( + organization_id=other.id, + email="other@example.com", + name="Other", + role=Role.owner, + password_hash=hash_password("Password123!"), + ) + workspace = Workspace(organization_id=other.id, name="Private", description="") + db.add_all([owner, viewer, other_user, workspace]) + db.commit() + db.close() + + +def token(client: TestClient, email: str) -> str: + response = client.post("/api/auth/login", json={"email": email, "password": "Password123!"}) + assert response.status_code == 200 + return response.json()["access_token"] + + +def test_auth_rbac_and_audit_logging(): + client = TestClient(app) + owner_token = token(client, "owner@example.com") + viewer_token = token(client, "viewer@example.com") + + denied = client.post( + "/api/workspaces", + headers={"Authorization": f"Bearer {viewer_token}"}, + json={"name": "Denied", "description": ""}, + ) + assert denied.status_code == 403 + + created = client.post( + "/api/workspaces", + headers={"Authorization": f"Bearer {owner_token}"}, + json={"name": "Enterprise", "description": "Shared team space"}, + ) + assert created.status_code == 200 + + audit = client.get("/api/audit", headers={"Authorization": f"Bearer {owner_token}"}) + assert audit.status_code == 200 + assert any(event["action"] == "workspace.create" for event in audit.json()) + + +def test_organization_isolation_for_workspace_runs(): + client = TestClient(app) + owner_token = token(client, "owner@example.com") + other_token = token(client, "other@example.com") + + workspace = client.post( + "/api/workspaces", + headers={"Authorization": f"Bearer {owner_token}"}, + json={"name": "Acme Workspace", "description": ""}, + ).json() + + response = client.post( + "/api/runs", + headers={"Authorization": f"Bearer {other_token}"}, + json={"workspace_id": workspace["id"], "prompt": "Research competitors"}, + ) + assert response.status_code == 404 + + +def test_run_creation_and_approval_gate(): + client = TestClient(app) + owner_token = token(client, "owner@example.com") + response = client.post( + "/api/runs", + headers={"Authorization": f"Bearer {owner_token}"}, + json={"prompt": "Generate a product launch video with Sora", "auto_start": False}, + ) + assert response.status_code == 201 + body = response.json() + assert body["state"] == "waiting_for_input" + assert body["cost_approval_required"] is True + + +def test_secret_encryption_api_access_control(): + client = TestClient(app) + owner_token = token(client, "owner@example.com") + viewer_token = token(client, "viewer@example.com") + + denied = client.post( + "/api/integrations/secrets", + headers={"Authorization": f"Bearer {viewer_token}"}, + json={"provider": "openai", "value": "sk-test-secret", "scope": "organization"}, + ) + assert denied.status_code == 403 + + created = client.post( + "/api/integrations/secrets", + headers={"Authorization": f"Bearer {owner_token}"}, + json={"provider": "openai", "value": "sk-test-secret", "scope": "organization"}, + ) + assert created.status_code == 200 + assert created.json()["masked_value"] == "sk-t...cret" + + db = SessionLocal() + try: + from enterprise.models import ProviderSecret + + stored = db.query(ProviderSecret).first() + assert stored.encrypted_value != "sk-test-secret" + assert decrypt_secret(stored.encrypted_value) == "sk-test-secret" + finally: + db.close() +