diff --git a/.env.example b/.env.example
index 3028d7bb..21e07709 100644
--- a/.env.example
+++ b/.env.example
@@ -17,3 +17,8 @@ ALPHATRION_CLICKHOUSE_ENABLE_BATCH=true
ALPHATRION_ENABLE_PROMETHEUS=false
ALPHATRION_PROMETHEUS_PUSHGATEWAY_URL=localhost:9091
ALPHATRION_PROMETHEUS_JOB_NAME=alphatrion
+
+# Authentication configurations
+# Set to true to enable JWT authentication, false to use direct headers (x-user-id, x-org-id)
+ALPHATRION_ENABLE_AUTH=true
+ALPHATRION_JWT_SECRET=your_jwt_secret_key_here
diff --git a/README.md b/README.md
index 4c8d1cfe..bc79bfaf 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
-Open, modular framework to build and optimize GenAI applications
+βοΈ The observability platform for agentic systems.
[](https://github.com/mkenney/software-guides/blob/master/STABILITY-BADGES.md#alpha)
@@ -25,11 +25,15 @@ Open, modular framework to build and optimize GenAI applications
- **π€ Model Distribution** - Analyze request patterns and usage across different AI models
- **π¦ Artifact Management** - Store and version execution results, checkpoints, and model outputs
- **π― Interactive Dashboard** - Modern web UI for exploring experiments, metrics, and traces
+- **π Secure Authentication** - JWT-based authentication with user profiles and multi-team support
+- **π₯ Multi-User Support** - Collaborative workspace with organization and team management
- **π Easy Integration** - Simple Python API with async/await support
## Core Concepts
-- **Team** - Top-level organizational unit for user collaboration
+- **Organization** - Top-level entity for grouping teams and users
+- **Team** - Collaborative workspace for organizing experiments and runs
+- **User** - Individual account with secure authentication and team memberships
- **Experiment** - Logical grouping of runs with shared purpose, organized by labels
- **Run** - Individual execution instance with configuration and metrics
@@ -56,12 +60,10 @@ make up
# Wait for services to be ready, then run migrations
make migrate
-# Initialize your team and user
-alphatrion init # Use -h for custom options
+# Initialize your organization, team, and user account
+alphatrion init
```
-Save the generated user ID β you'll need it to track experiments.
-
**Optional Tools:**
- pgAdmin: `http://localhost:8081` (alphatrion@inftyai.com / alphatr1on)
- Registry UI: `http://localhost:80`
@@ -96,7 +98,7 @@ alphatrion server
alphatrion dashboard
```
-Access the dashboard at `http://127.0.0.1:5173` to explore experiments, visualize metrics, and analyze traces.
+Access the dashboard at `http://127.0.0.1:5173` and **log in with your email and password** to explore experiments, visualize metrics, and analyze traces.

@@ -106,13 +108,6 @@ AlphaTrion automatically captures distributed tracing data for all LLM calls, in

-### 6. Other APIs
-
-- **log_params**: Track hyperparameters and configuration settings
-- **log_metrics**: Record performance metrics and visualize trends
-- **log_artifacts**: Store and manage files, checkpoints, and model outputs
-
-
### Cleanup
```bash
diff --git a/alphatrion/envs.py b/alphatrion/envs.py
index 0f379a49..46662fe7 100644
--- a/alphatrion/envs.py
+++ b/alphatrion/envs.py
@@ -25,3 +25,6 @@
# Runtime related envs
ROOT_PATH = "ALPHATRION_ROOT_PATH"
+
+ENABLE_AUTH = "ALPHATRION_ENABLE_AUTH"
+JWT_SECRET = "ALPHATRION_JWT_SECRET"
diff --git a/alphatrion/server/auth.py b/alphatrion/server/auth.py
new file mode 100644
index 00000000..7c13ebf2
--- /dev/null
+++ b/alphatrion/server/auth.py
@@ -0,0 +1,90 @@
+"""Authentication utilities for JWT-based authentication."""
+
+import os
+from datetime import UTC, datetime, timedelta
+from typing import Any
+
+import bcrypt
+from jose import JWTError, jwt
+
+from alphatrion import envs
+
+# JWT settings
+SECRET_KEY = os.getenv(envs.JWT_SECRET, "your-secret-key-change-this-in-production")
+ALGORITHM = "HS256"
+ACCESS_TOKEN_EXPIRE_DAYS = 7
+
+
+def hash_password(password: str) -> str:
+ """Hash a password using bcrypt.
+
+ Args:
+ password: Plain text password
+
+ Returns:
+ Bcrypt hash of the password
+ """
+ # Convert password to bytes
+ password_bytes = password.encode("utf-8")
+ # Generate salt and hash
+ salt = bcrypt.gensalt()
+ hashed = bcrypt.hashpw(password_bytes, salt)
+ # Return as string
+ return hashed.decode("utf-8")
+
+
+def verify_password(plain_password: str, hashed_password: str) -> bool:
+ """Verify a password against a bcrypt hash.
+
+ Args:
+ plain_password: Plain text password to verify
+ hashed_password: Bcrypt hash to verify against
+
+ Returns:
+ True if password matches, False otherwise
+ """
+ try:
+ password_bytes = plain_password.encode("utf-8")
+ hashed_bytes = hashed_password.encode("utf-8")
+ return bcrypt.checkpw(password_bytes, hashed_bytes)
+ except Exception:
+ return False
+
+
+def create_access_token(
+ data: dict[str, Any], expires_delta: timedelta | None = None
+) -> str:
+ """Create a JWT access token.
+
+ Args:
+ data: Dictionary containing claims to encode in the token
+ expires_delta: Optional token expiration time
+
+ Returns:
+ Encoded JWT token string
+ """
+ to_encode = data.copy()
+ if expires_delta:
+ expire = datetime.now(UTC) + expires_delta
+ else:
+ expire = datetime.now(UTC) + timedelta(days=ACCESS_TOKEN_EXPIRE_DAYS)
+
+ to_encode.update({"exp": expire})
+ encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
+ return encoded_jwt
+
+
+def decode_access_token(token: str) -> dict[str, Any] | None:
+ """Decode and validate a JWT access token.
+
+ Args:
+ token: JWT token string
+
+ Returns:
+ Dictionary containing the decoded claims, or None if invalid
+ """
+ try:
+ payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
+ return payload
+ except JWTError:
+ return None
diff --git a/alphatrion/server/cmd/app.py b/alphatrion/server/cmd/app.py
index 8749ac62..b8cccc5a 100644
--- a/alphatrion/server/cmd/app.py
+++ b/alphatrion/server/cmd/app.py
@@ -2,14 +2,23 @@
# ruff: noqa: B904
import logging
+import uuid
from importlib.metadata import version
-from fastapi import FastAPI, Request
+from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
+from pydantic import BaseModel
from strawberry.fastapi import GraphQLRouter
+from alphatrion.server.auth import (
+ create_access_token,
+ decode_access_token,
+ hash_password,
+ verify_password,
+)
from alphatrion.server.graphql.context import get_context
from alphatrion.server.graphql.schema import schema
+from alphatrion.storage import runtime
# Configure logging
logger = logging.getLogger(__name__)
@@ -117,3 +126,133 @@ def health_check():
@app.get("/version")
def get_version():
return {"version": version("alphatrion"), "status": "ok"}
+
+
+# Auth endpoints
+class LoginRequest(BaseModel):
+ email: str
+ password: str
+
+
+class LoginResponse(BaseModel):
+ access_token: str
+ token_type: str
+ user: dict
+
+
+@app.post("/api/auth/login", response_model=LoginResponse)
+async def login(credentials: LoginRequest):
+ """Authenticate user and return JWT token with user information."""
+ try:
+ metadb = runtime.storage_runtime().metadb
+
+ # Find user by email
+ user = metadb.get_user_by_email(email=credentials.email)
+
+ if not user:
+ raise HTTPException(status_code=401, detail="Invalid email or password")
+
+ # Verify password
+ if not verify_password(credentials.password, user.password_hash):
+ raise HTTPException(status_code=401, detail="Invalid email or password")
+
+ # Get user's teams
+ team_members = metadb.get_team_members_by_user_id(user_id=user.uuid)
+ teams = []
+ for member in team_members:
+ team = metadb.get_team(team_id=member.team_id)
+ if team:
+ teams.append(
+ {
+ "id": str(team.uuid),
+ "name": team.name,
+ "description": team.description,
+ }
+ )
+
+ # Create JWT token with user claims
+ # Note: team_id is NOT included - users can belong to multiple teams
+ # Team selection is handled in the UI
+ token_data = {
+ "sub": str(user.uuid), # subject = user_id
+ "user_id": str(user.uuid),
+ "org_id": str(user.org_id),
+ "email": user.email,
+ }
+
+ access_token = create_access_token(data=token_data)
+
+ # Return token and user info
+ return {
+ "access_token": access_token,
+ "token_type": "bearer",
+ "user": {
+ "id": str(user.uuid),
+ "name": user.name,
+ "email": user.email,
+ "avatarUrl": user.avatar_url,
+ "meta": user.meta,
+ "createdAt": user.created_at.isoformat(),
+ "updatedAt": user.updated_at.isoformat(),
+ "teams": teams,
+ },
+ }
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Login failed: {e}")
+ raise HTTPException(status_code=500, detail="Internal server error")
+
+
+class ChangePasswordRequest(BaseModel):
+ current_password: str
+ new_password: str
+
+
+@app.post("/api/auth/change-password")
+async def change_password(request: Request, password_data: ChangePasswordRequest):
+ """Change user's password."""
+ try:
+ # Extract token from Authorization header
+ auth_header = request.headers.get("Authorization")
+ if not auth_header or not auth_header.startswith("Bearer "):
+ raise HTTPException(
+ status_code=401, detail="Missing or invalid authorization header"
+ )
+
+ token = auth_header.replace("Bearer ", "")
+
+ # Decode token to get user_id
+ payload = decode_access_token(token)
+ if not payload:
+ raise HTTPException(status_code=401, detail="Invalid or expired token")
+
+ user_id = payload.get("user_id")
+ if not user_id:
+ raise HTTPException(status_code=401, detail="Invalid token payload")
+
+ metadb = runtime.storage_runtime().metadb
+
+ # Get user from database
+ user = metadb.get_user(user_id=uuid.UUID(user_id))
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+
+ # Verify current password
+ if not verify_password(password_data.current_password, user.password_hash):
+ raise HTTPException(status_code=401, detail="Current password is incorrect")
+
+ # Hash new password
+ new_password_hash = hash_password(password_data.new_password)
+
+ # Update password in database
+ metadb.update_user(user_id=uuid.UUID(user_id), password_hash=new_password_hash)
+
+ return {"message": "Password changed successfully"}
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Password change failed: {e}")
+ raise HTTPException(status_code=500, detail="Internal server error")
diff --git a/alphatrion/server/cmd/main.py b/alphatrion/server/cmd/main.py
index 881c5e12..0d49ae69 100644
--- a/alphatrion/server/cmd/main.py
+++ b/alphatrion/server/cmd/main.py
@@ -21,13 +21,14 @@
from rich.console import Console
from rich.text import Text
-from alphatrion import envs
from alphatrion.storage import runtime
from alphatrion.utils import log
load_dotenv()
console = Console()
+character_length_at_least = 6
+
try:
__version__ = version("alphatrion")
except PackageNotFoundError:
@@ -62,18 +63,6 @@ def main():
default="http://localhost:8000",
help="Backend server URL to proxy requests to (default: http://localhost:8000)",
)
- dashboard.add_argument(
- "--user-id",
- type=str,
- default=os.getenv(envs.DASHBOARD_USER_ID),
- help="User ID to scope the dashboard (required)",
- )
- dashboard.add_argument(
- "--team-id",
- type=str,
- default=None,
- help="Team ID to scope the dashboard (optional)",
- )
dashboard.set_defaults(func=start_dashboard)
# init command
@@ -84,25 +73,31 @@ def main():
"--user-name",
type=str,
default=None,
- help="Username for the new user (auto-generated if not provided)",
+ help="Username for the new user (will prompt if not provided)",
)
init.add_argument(
"--email",
type=str,
default=None,
- help="Email for the new user (auto-generated if not provided)",
+ help="Email for the new user (will prompt if not provided)",
+ )
+ init.add_argument(
+ "--password",
+ type=str,
+ default=None,
+ help="Password for the new user (will prompt if not provided)",
)
init.add_argument(
"--team-name",
type=str,
- default="Default Team",
- help="Team name (default: Default Team)",
+ default=None,
+ help="Team name (will prompt if not provided, defaults to 'Default Team')",
)
init.add_argument(
"--org-name",
type=str,
default=None,
- help="Organization name (auto-generated if not provided)",
+ help="Organization name (will prompt if not provided, auto-generated if empty)",
)
init.set_defaults(func=init_command)
@@ -166,20 +161,78 @@ def main():
def init_command(args):
"""Initialize AlphaTrion with a user and team."""
+ import getpass
+
+ from alphatrion.server.auth import hash_password
+
# Initialize the Server runtime to get access to metadb
runtime.init()
fake = Faker()
- # Generate user name if not provided
- user_name = args.user_name if args.user_name else fake.name()
- email = (
- args.email
- if args.email
- else f"{user_name.lower().replace(' ', '.')}@inftyai.com"
- )
+ # Prompt for user info if not provided
+ user_name = args.user_name
+ if not user_name:
+ user_name = console.input(Text("π€ Enter user name: ", style="cyan"))
+ if not user_name:
+ user_name = fake.name()
+ console.print(Text(f" Using generated name: {user_name}", style="dim"))
+
+ email = args.email
+ if not email:
+ email = console.input(Text("π§ Enter email address: ", style="cyan"))
+ if not email:
+ email = f"{user_name.lower().replace(' ', '.')}@inftyai.com"
+ console.print(Text(f" Using generated email: {email}", style="dim"))
+
+ password = args.password
+ if not password:
+ console.print(Text("π Enter password for the user:", style="cyan"))
+ password = getpass.getpass(" Password: ")
+ if not password:
+ console.print(
+ Text(
+ "β Error: Password is required for authentication",
+ style="bold red",
+ )
+ )
+ return
+ confirm = getpass.getpass(" Confirm password: ")
+ if password != confirm:
+ console.print(Text("β Error: Passwords do not match", style="bold red"))
+ return
+
+ if len(password) < character_length_at_least:
+ console.print(
+ Text(f"β Error: Password must be at least {character_length_at_least} characters", style="bold red")
+ )
+ return
+
+ org_name = args.org_name
+ if not org_name:
+ org_name = console.input(
+ Text(
+ "π’ Enter organization name (press Enter to auto-generate): ",
+ style="cyan",
+ )
+ )
+ if not org_name:
+ org_name = fake.company()
+ console.print(
+ Text(f" Using generated organization: {org_name}", style="dim")
+ )
+
team_name = args.team_name
- org_name = args.org_name if args.org_name else fake.company()
+ if not team_name:
+ team_name = console.input(
+ Text("π₯ Enter team name (press Enter for 'Default Team'): ", style="cyan")
+ )
+ if not team_name:
+ team_name = "Default Team"
+ console.print(Text(f" Using default team: {team_name}", style="dim"))
+
+ # Hash password
+ password_hash = hash_password(password)
try:
metadb = runtime.storage_runtime().metadb
@@ -188,11 +241,14 @@ def init_command(args):
# Create organization
console.print(Text(f"π’ Creating organization: {org_name}", style="bold cyan"))
org_id = metadb.create_organization(name=org_name)
- # Create user
+
+ # Create user with password
console.print(
Text(f"π€ Creating user: {user_name} ({email})", style="bold cyan")
)
- user_id = metadb.create_user(name=user_name, email=email, org_id=org_id)
+ user_id = metadb.create_user(
+ name=user_name, email=email, org_id=org_id, password_hash=password_hash
+ )
# Create team
console.print(Text(f"π’ Creating team: {team_name}", style="bold cyan"))
@@ -205,27 +261,28 @@ def init_command(args):
console.print()
console.print(Text("β
Initialization successful!", style="bold green"))
console.print()
- console.print(Text("π Your organization ID:", style="bold yellow"))
- console.print(Text(f" {org_id}", style="bold cyan"))
- console.print(Text(" Your team ID:", style="bold yellow"))
- console.print(Text(f" {team_id}", style="bold cyan"))
- console.print(Text(" Your user ID:", style="bold yellow"))
- console.print(Text(f" {user_id}", style="bold cyan"))
+ console.print(Text("π Account Information:", style="bold yellow"))
+ console.print(Text(f" Email: {email}", style="cyan"))
+ console.print(Text(f" User ID: {user_id}", style="cyan"))
+ console.print(Text(f" Organization ID: {org_id}", style="cyan"))
+ console.print(Text(f" Team ID: {team_id}", style="cyan"))
+ console.print()
+ console.print(Text("π Next steps:", style="bold yellow"))
+ console.print(Text(" 1. Start the backend server:", style="dim"))
+ console.print(Text(" alphatrion server", style="white"))
+ console.print()
+ console.print(Text(" 2. Start the dashboard:", style="dim"))
+ console.print(Text(" alphatrion dashboard", style="white"))
console.print()
console.print(
- Text(
- "π‘ Use this user ID to launch the dashboard, "
- "or set the ALPHATRION_DASHBOARD_USER_ID environment variable",
- style="dim",
- )
- )
- console.print(
- Text(f" alphatrion dashboard --user-id {user_id}", style="magenta")
+ Text(" 3. Visit http://localhost:5173 and login with:", style="dim")
)
+ console.print(Text(f" Email: {email}", style="white"))
+ console.print(Text(" Password: ", style="white"))
console.print()
console.print(
Text(
- "π Use this user ID and team ID to setup the experiment environment:",
+ "π‘ To use in Python experiments:",
style="dim",
)
)
@@ -504,7 +561,6 @@ def start_dashboard(args):
console.print(
Text(f"π Proxying backend requests to: {args.backend_url}", style="dim")
)
- console.print(Text(f"π€ Dashboard scoped to user: {args.user_id}", style="yellow"))
console.print()
console.print(
Text("π‘ Note: Make sure the backend server is running:", style="bold yellow")
@@ -514,64 +570,9 @@ def start_dashboard(args):
app = FastAPI()
- if not args.user_id:
- console.print(
- Text(
- "β Error: User ID is required to launch the dashboard!",
- style="bold red",
- )
- )
- console.print(
- Text(
- "Please provide a user ID using the --user-id argument or set the ALPHATRION_DASHBOARD_USER_ID environment variable.",
- style="yellow",
- )
- )
- console.print(
- Text(
- "You can create a user and get their ID by running: alphatrion init",
- style="cyan",
- )
- )
- return
- # Store user ID in app state
- app.state.user_id = args.user_id
- if args.team_id:
- app.state.team_id = args.team_id
-
# Create HTTP client for proxying requests to backend
http_client = httpx.AsyncClient(base_url=args.backend_url, timeout=30.0)
- # Endpoint to get current user ID and org ID (for frontend)
- @app.get("/api/config")
- async def get_config():
- import contextlib
- import uuid
-
- from alphatrion.storage import runtime as storage_runtime
-
- # Initialize storage if not already done
- with contextlib.suppress(Exception):
- storage_runtime.init()
-
- config = {"userId": app.state.user_id}
-
- # Look up user's org_id
- try:
- metadb = storage_runtime.storage_runtime().metadb
- user = metadb.get_user(user_id=uuid.UUID(app.state.user_id))
- if user:
- config["orgId"] = str(user.org_id)
- except Exception as e:
- console.print(
- Text(f"Warning: Could not fetch user org_id: {e}", style="yellow")
- )
-
- if hasattr(app.state, "team_id"):
- config["teamId"] = app.state.team_id
-
- return config
-
# Proxy /graphql requests to backend (MUST be before catch-all route)
@app.api_route("/graphql", methods=["GET", "POST"])
async def proxy_graphql(request: Request):
diff --git a/alphatrion/server/graphql/context.py b/alphatrion/server/graphql/context.py
index c2ccc380..65a8766b 100644
--- a/alphatrion/server/graphql/context.py
+++ b/alphatrion/server/graphql/context.py
@@ -1,11 +1,16 @@
"""GraphQL context for request-scoped data."""
+import os
+
from fastapi import Request
from strawberry.fastapi import BaseContext
+from alphatrion import envs
+from alphatrion.server.auth import decode_access_token
+
class GraphQLContext(BaseContext):
- """Context object containing request-scoped data extracted from headers."""
+ """Context object containing request-scoped data extracted from JWT or headers."""
def __init__(self, org_id: str, user_id: str, request: Request):
super().__init__()
@@ -15,11 +20,14 @@ def __init__(self, org_id: str, user_id: str, request: Request):
async def get_context(request: Request) -> GraphQLContext:
- """Extract org_id, and user_id from request headers.
+ """Extract org_id and user_id from JWT token or request headers.
- Expected headers:
- - x-org-id: Organization ID
- - x-user-id: User ID
+ Authentication mode is controlled by ALPHATRION_ENABLE_AUTH environment variable:
+ - True: Use JWT authentication (Authorization: Bearer )
+ - False: Use direct headers (x-org-id, x-user-id)
+
+ Note: team_id is NOT included in the context as users can belong to multiple teams.
+ Team selection is handled at the application level.
Args:
request: FastAPI Request object
@@ -28,18 +36,41 @@ async def get_context(request: Request) -> GraphQLContext:
GraphQLContext with extracted IDs
Raises:
- ValueError: If required headers are missing
+ ValueError: If authentication fails or required data is missing
"""
- org_id = request.headers.get("x-org-id")
- user_id = request.headers.get("x-user-id")
+ org_id = None
+ user_id = None
+
+ # Check if JWT authentication is enabled
+ enable_auth = os.getenv(envs.ENABLE_AUTH, "true").lower() == "true"
+
+ if enable_auth:
+ # JWT authentication mode
+ auth_header = request.headers.get("authorization")
+ if not auth_header or not auth_header.startswith("Bearer "):
+ raise ValueError("Missing or invalid Authorization header")
+
+ token = auth_header.split(" ")[1]
+ payload = decode_access_token(token)
+
+ if not payload:
+ raise ValueError("Invalid or expired JWT token")
+
+ org_id = payload.get("org_id")
+ user_id = payload.get("user_id")
+ else:
+ # Direct headers mode
+ org_id = request.headers.get("x-org-id")
+ user_id = request.headers.get("x-user-id")
+ # Validate required fields
if not org_id or not user_id:
missing = []
if not org_id:
- missing.append("x-org-id")
+ missing.append("org_id" if enable_auth else "x-org-id")
if not user_id:
- missing.append("x-user-id")
- raise ValueError(f"Missing required headers: {', '.join(missing)}")
+ missing.append("user_id" if enable_auth else "x-user-id")
+ raise ValueError(f"Missing required fields: {', '.join(missing)}")
return GraphQLContext(
org_id=org_id,
diff --git a/alphatrion/server/graphql/schema.py b/alphatrion/server/graphql/schema.py
index 427cb08a..312f9515 100644
--- a/alphatrion/server/graphql/schema.py
+++ b/alphatrion/server/graphql/schema.py
@@ -137,7 +137,9 @@ def daily_token_usage(
# Artifact queries
@strawberry.field
- async def artifact_repos(self, info: Info[GraphQLContext, None]) -> list[ArtifactRepository]:
+ async def artifact_repos(
+ self, info: Info[GraphQLContext, None]
+ ) -> list[ArtifactRepository]:
return await GraphQLResolvers.list_artifact_repositories(info)
@strawberry.field
@@ -157,7 +159,9 @@ async def artifact_files(
tag: str,
repo_name: str,
) -> list[ArtifactFile]:
- return await GraphQLResolvers.list_artifact_files(info, str(team_id), tag, repo_name)
+ return await GraphQLResolvers.list_artifact_files(
+ info, str(team_id), tag, repo_name
+ )
@strawberry.field
async def artifact_content(
diff --git a/alphatrion/storage/metastore.py b/alphatrion/storage/metastore.py
index b7169728..6aac370e 100644
--- a/alphatrion/storage/metastore.py
+++ b/alphatrion/storage/metastore.py
@@ -27,6 +27,7 @@ def create_user(
name: str,
email: str,
org_id: uuid.UUID,
+ password_hash: str | None = None,
avatar_url: str | None = None,
team_id: uuid.UUID | None = None,
meta: dict | None = None,
@@ -37,6 +38,10 @@ def create_user(
def get_user(self, user_id: uuid.UUID) -> User | None:
raise NotImplementedError("Subclasses must implement this method.")
+ @abstractmethod
+ def get_user_by_email(self, email: str) -> User | None:
+ raise NotImplementedError("Subclasses must implement this method.")
+
@abstractmethod
def list_users(
self, team_id: uuid.UUID, page: int = 0, page_size: int = 10
diff --git a/alphatrion/storage/sql_models.py b/alphatrion/storage/sql_models.py
index dfc17868..f5cac099 100644
--- a/alphatrion/storage/sql_models.py
+++ b/alphatrion/storage/sql_models.py
@@ -90,6 +90,7 @@ class User(Base):
org_id = Column(UUID(as_uuid=True), nullable=False, comment="Organization ID")
name = Column(String, nullable=False)
email = Column(String, nullable=False, unique=True)
+ password_hash = Column(String, nullable=False, comment="Bcrypt hashed password")
avatar_url = Column(String, nullable=True)
meta = Column(
MutableDict.as_mutable(JSON),
diff --git a/alphatrion/storage/sqlstore.py b/alphatrion/storage/sqlstore.py
index 892568e2..e0acd7fa 100644
--- a/alphatrion/storage/sqlstore.py
+++ b/alphatrion/storage/sqlstore.py
@@ -191,15 +191,23 @@ def create_user(
name: str,
email: str,
org_id: uuid.UUID,
+ password_hash: str | None = None,
uuid: uuid.UUID | None = None,
avatar_url: str | None = None,
team_id: uuid.UUID | None = None,
meta: dict | None = None,
) -> uuid.UUID:
+ # If no password_hash provided, use default (user must change on first login)
+ if password_hash is None:
+ from alphatrion.server.auth import hash_password
+
+ password_hash = hash_password("changeme123")
+
user = User(
org_id=org_id,
name=name,
email=email,
+ password_hash=password_hash,
avatar_url=avatar_url,
meta=meta,
)
@@ -248,6 +256,12 @@ def get_user(self, user_id: uuid.UUID) -> User | None:
session.close()
return user
+ def get_user_by_email(self, email: str) -> User | None:
+ session = self._session()
+ user = session.query(User).filter(User.email == email, User.is_del == 0).first()
+ session.close()
+ return user
+
def update_user(self, user_id: uuid.UUID, **kwargs) -> User | None:
session = self._session()
user = (
diff --git a/charts/alphatrion/templates/dashboard-deployment.yaml b/charts/alphatrion/templates/dashboard-deployment.yaml
index 476ff608..1eb2f587 100644
--- a/charts/alphatrion/templates/dashboard-deployment.yaml
+++ b/charts/alphatrion/templates/dashboard-deployment.yaml
@@ -47,20 +47,19 @@ spec:
image: "{{ .Values.dashboard.image.repository }}:{{ .Values.dashboard.image.tag }}"
imagePullPolicy: {{ .Values.dashboard.image.pullPolicy }}
- # TODO: Remove after quantinuum-nvidia collaboration is done.
- env:
- - name: ALPHATRION_DASHBOARD_USER_ID
- value: {{ .Values.dashboard.userId | quote }}
- {{- if .Values.dashboard.teamId }}
- - name: ALPHATRION_DASHBOARD_TEAM_ID
- value: {{ .Values.dashboard.teamId | quote }}
- {{- end }}
-
ports:
- name: http
containerPort: 8080
protocol: TCP
+ {{- if .Values.dashboard.env }}
+ env:
+ {{- if .Values.dashboard.env.apiUrl }}
+ - name: VITE_API_URL
+ value: {{ .Values.dashboard.env.apiUrl | quote }}
+ {{- end }}
+ {{- end }}
+
{{- if .Values.dashboard.livenessProbe.enabled }}
livenessProbe:
httpGet:
diff --git a/charts/alphatrion/templates/server-configmap.yaml b/charts/alphatrion/templates/server-configmap.yaml
index ba082e2f..3a9ae897 100644
--- a/charts/alphatrion/templates/server-configmap.yaml
+++ b/charts/alphatrion/templates/server-configmap.yaml
@@ -31,3 +31,6 @@ data:
ALPHATRION_ARTIFACT_REGISTRY_URL: {{ .Values.registry.url | quote }}
ALPHATRION_ARTIFACT_INSECURE: {{ .Values.registry.insecure | quote }}
{{- end }}
+
+ # Authentication configuration
+ ALPHATRION_ENABLE_AUTH: {{ .Values.server.env.enableAuth | quote }}
diff --git a/charts/alphatrion/values.yaml b/charts/alphatrion/values.yaml
index 0fb7f5eb..81122a5f 100644
--- a/charts/alphatrion/values.yaml
+++ b/charts/alphatrion/values.yaml
@@ -34,6 +34,8 @@ server:
enableArtifactStorage: false
# Root path for API (useful if behind a reverse proxy)
rootPath: ""
+ # Enable JWT authentication (true) or use direct headers (false)
+ enableAuth: false
# Health check configuration
livenessProbe:
@@ -71,6 +73,12 @@ dashboard:
type: ClusterIP
port: 80
+ env:
+ # Backend API URL - set this to the server service URL
+ # For internal cluster communication: http://alphatrion-server:8000
+ # For external access: https://api.example.com
+ apiUrl: ""
+
# Health check configuration
livenessProbe:
enabled: true
diff --git a/dashboard/Caddyfile b/dashboard/Caddyfile
deleted file mode 100644
index 705e43a2..00000000
--- a/dashboard/Caddyfile
+++ /dev/null
@@ -1,17 +0,0 @@
-:8080 {
- # Proxy API and GraphQL requests to backend (must be first)
- handle /api/* {
- reverse_proxy alphatrion-server:8000
- }
-
- handle /graphql {
- reverse_proxy alphatrion-server:8000
- }
-
- # Serve static files and SPA
- handle {
- root * /usr/share/caddy
- try_files {path} /index.html
- file_server
- }
-}
diff --git a/dashboard/DEPLOYMENT.md b/dashboard/DEPLOYMENT.md
new file mode 100644
index 00000000..c562a38a
--- /dev/null
+++ b/dashboard/DEPLOYMENT.md
@@ -0,0 +1,111 @@
+# Dashboard Deployment Guide
+
+The AlphaTrion dashboard supports multiple deployment scenarios:
+
+## Local Development
+
+For local development with the proxy:
+
+```bash
+npm run dev
+```
+
+The dashboard will use the Vite proxy to forward API requests to `http://localhost:8000`.
+
+## Docker Deployment
+
+### Build the Docker image:
+
+```bash
+docker build -t alphatrion-dashboard:latest .
+```
+
+### Run with environment variable:
+
+```bash
+docker run -p 8080:8080 \
+ -e VITE_API_URL=http://localhost:8000 \
+ alphatrion-dashboard:latest
+```
+
+## Kubernetes Deployment
+
+The dashboard can be deployed separately from the backend in Kubernetes.
+
+### Configure the backend API URL in `values.yaml`:
+
+```yaml
+dashboard:
+ env:
+ # For internal cluster communication
+ apiUrl: "http://alphatrion-server:8000"
+
+ # For external access through ingress
+ # apiUrl: "https://api.example.com"
+```
+
+### Deploy with Helm:
+
+```bash
+helm install alphatrion ./charts/alphatrion \
+ --set dashboard.env.apiUrl=http://alphatrion-server:8000
+```
+
+## How It Works
+
+The dashboard supports three layers of configuration (in order of precedence):
+
+1. **Runtime config** (Kubernetes): `window.ENV.VITE_API_URL` - injected by `entrypoint.sh` at container startup
+2. **Build-time env** (Docker): `import.meta.env.VITE_API_URL` - set during `npm run build`
+3. **Relative URL** (Local dev): Empty string - uses Vite proxy
+
+### Runtime Configuration
+
+In production (Docker/Kubernetes), the `entrypoint.sh` script generates a `/config.js` file with:
+
+```javascript
+window.ENV = {
+ VITE_API_URL: "http://alphatrion-server:8000"
+};
+```
+
+This is loaded before the main application and provides runtime configuration without rebuilding the image.
+
+## Environment Variables
+
+- `VITE_API_URL`: Backend API base URL (e.g., `http://alphatrion-server:8000` or `https://api.example.com`)
+ - Leave empty for local development with proxy
+ - Set in Docker run command or Kubernetes deployment for production
+
+## Architecture
+
+```
+βββββββββββββββββββββββ
+β User Browser β
+ββββββββββββ¬βββββββββββ
+ β
+ β HTTPS
+ β
+ββββββββββββΌβββββββββββ
+β Ingress/LB β
+β (optional) β
+ββββββββββββ¬βββββββββββ
+ β
+ βββββββ΄ββββββ
+ β β
+ββββββΌβββββ βββββΌββββββ
+βDashboardβ β Backend β
+β (nginx) β β (API) β
+β :8080 β β :8000 β
+βββββββββββ βββββββββββ
+```
+
+The dashboard is a static single-page application served by nginx. It communicates with the backend API using the configured `VITE_API_URL`.
+
+## Why nginx?
+
+The dashboard needs a web server to:
+1. **Serve static files over HTTP** - HTML, JavaScript, CSS, images
+2. **Handle SPA routing** - Return `index.html` for all routes (e.g., `/experiments/123`) so React Router can handle client-side routing
+
+nginx is the industry-standard choice for serving static content in Kubernetes environments.
diff --git a/dashboard/Dockerfile b/dashboard/Dockerfile
index 6975e89a..8c933153 100644
--- a/dashboard/Dockerfile
+++ b/dashboard/Dockerfile
@@ -6,20 +6,26 @@ RUN npm install
COPY . .
RUN npm run build
-FROM caddy:2-alpine
+FROM nginx:alpine
-# Create non-root user and set up directories
-RUN adduser -D -u 1000 -g 1000 caddy && \
- mkdir -p /usr/share/caddy /config /data && \
- chown -R caddy:caddy /usr/share/caddy /config /data
+# Copy built static files
+COPY --from=build /app/static/ /usr/share/nginx/html/
-# Copy index.html to root, assets to /static/assets
-COPY --from=build /app/static/index.html /usr/share/caddy/
-COPY --from=build /app/static/assets /usr/share/caddy/static/assets
+# Copy nginx configuration
+COPY nginx.conf /etc/nginx/conf.d/default.conf
-COPY Caddyfile /etc/caddy/Caddyfile
+# Copy entrypoint script
+COPY entrypoint.sh /entrypoint.sh
+RUN chmod +x /entrypoint.sh
-# Switch to non-root user
-USER caddy
+# Create non-root user
+RUN adduser -D -u 1000 nginx-user && \
+ chown -R nginx-user:nginx-user /usr/share/nginx/html /var/cache/nginx /var/log/nginx && \
+ touch /var/run/nginx.pid && \
+ chown -R nginx-user:nginx-user /var/run/nginx.pid
-EXPOSE 8080
\ No newline at end of file
+USER nginx-user
+
+EXPOSE 8080
+
+ENTRYPOINT ["/entrypoint.sh"]
diff --git a/dashboard/entrypoint.sh b/dashboard/entrypoint.sh
new file mode 100644
index 00000000..5ac72e45
--- /dev/null
+++ b/dashboard/entrypoint.sh
@@ -0,0 +1,16 @@
+#!/bin/sh
+set -e
+
+# Generate runtime configuration file
+# This allows runtime configuration of the backend API URL
+cat > /usr/share/nginx/html/config.js <
+
+