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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,16 +78,26 @@ kubectl port-forward -n streamspace svc/streamspace-ui 8080:80

Open your browser to: `http://localhost:8080`

### 2. Create Your First User
### 2. Log In with Admin Account

**Using Local Authentication**:
**Retrieve Admin Credentials** (Helm deployment):

```bash
# Create user via API or web UI
# Default admin credentials are set during installation
# See chart/values.yaml for configuration
# Get auto-generated admin password
kubectl get secret streamspace-admin-credentials \
-n streamspace \
-o jsonpath='{.data.password}' | base64 -d && echo

# Username: admin
# Email: admin@streamspace.local
```

**Alternative Methods**:
- **Environment Variable**: Set `ADMIN_PASSWORD` in API deployment
- **Setup Wizard**: Visit `/setup` if no password is configured

See [Admin Onboarding Guide](docs/ADMIN_ONBOARDING.md) for complete details.

**Using SSO** (Authentik/Keycloak):

Configure OIDC or SAML in `chart/values.yaml`:
Expand Down
38 changes: 20 additions & 18 deletions api/cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ func main() {
schedulingHandler := handlers.NewSchedulingHandler(database)
securityHandler := handlers.NewSecurityHandler(database)
templateVersioningHandler := handlers.NewTemplateVersioningHandler(database)
setupHandler := handlers.NewSetupHandler(database)
// NOTE: Billing is now handled by the streamspace-billing plugin

// SECURITY: Initialize webhook authentication
Expand All @@ -268,7 +269,7 @@ func main() {
}

// Setup routes
setupRoutes(router, apiHandler, userHandler, groupHandler, authHandler, activityHandler, catalogHandler, sharingHandler, pluginHandler, dashboardHandler, sessionActivityHandler, apiKeyHandler, teamHandler, preferencesHandler, notificationsHandler, searchHandler, sessionTemplatesHandler, batchHandler, monitoringHandler, quotasHandler, websocketHandler, consoleHandler, collaborationHandler, integrationsHandler, loadBalancingHandler, schedulingHandler, securityHandler, templateVersioningHandler, jwtManager, userDB, redisCache, webhookSecret)
setupRoutes(router, apiHandler, userHandler, groupHandler, authHandler, activityHandler, catalogHandler, sharingHandler, pluginHandler, dashboardHandler, sessionActivityHandler, apiKeyHandler, teamHandler, preferencesHandler, notificationsHandler, searchHandler, sessionTemplatesHandler, batchHandler, monitoringHandler, quotasHandler, websocketHandler, consoleHandler, collaborationHandler, integrationsHandler, loadBalancingHandler, schedulingHandler, securityHandler, templateVersioningHandler, setupHandler, jwtManager, userDB, redisCache, webhookSecret)

// Create HTTP server with security timeouts
srv := &http.Server{
Expand Down Expand Up @@ -349,7 +350,7 @@ func main() {
log.Println("Graceful shutdown completed")
}

func setupRoutes(router *gin.Engine, h *api.Handler, userHandler *handlers.UserHandler, groupHandler *handlers.GroupHandler, authHandler *auth.AuthHandler, activityHandler *handlers.ActivityHandler, catalogHandler *handlers.CatalogHandler, sharingHandler *handlers.SharingHandler, pluginHandler *handlers.PluginHandler, dashboardHandler *handlers.DashboardHandler, sessionActivityHandler *handlers.SessionActivityHandler, apiKeyHandler *handlers.APIKeyHandler, teamHandler *handlers.TeamHandler, preferencesHandler *handlers.PreferencesHandler, notificationsHandler *handlers.NotificationsHandler, searchHandler *handlers.SearchHandler, sessionTemplatesHandler *handlers.SessionTemplatesHandler, batchHandler *handlers.BatchHandler, monitoringHandler *handlers.MonitoringHandler, quotasHandler *handlers.QuotasHandler, websocketHandler *handlers.WebSocketHandler, consoleHandler *handlers.ConsoleHandler, collaborationHandler *handlers.CollaborationHandler, integrationsHandler *handlers.IntegrationsHandler, loadBalancingHandler *handlers.LoadBalancingHandler, schedulingHandler *handlers.SchedulingHandler, securityHandler *handlers.SecurityHandler, templateVersioningHandler *handlers.TemplateVersioningHandler, jwtManager *auth.JWTManager, userDB *db.UserDB, redisCache *cache.Cache, webhookSecret string) {
func setupRoutes(router *gin.Engine, h *api.Handler, userHandler *handlers.UserHandler, groupHandler *handlers.GroupHandler, authHandler *auth.AuthHandler, activityHandler *handlers.ActivityHandler, catalogHandler *handlers.CatalogHandler, sharingHandler *handlers.SharingHandler, pluginHandler *handlers.PluginHandler, dashboardHandler *handlers.DashboardHandler, sessionActivityHandler *handlers.SessionActivityHandler, apiKeyHandler *handlers.APIKeyHandler, teamHandler *handlers.TeamHandler, preferencesHandler *handlers.PreferencesHandler, notificationsHandler *handlers.NotificationsHandler, searchHandler *handlers.SearchHandler, sessionTemplatesHandler *handlers.SessionTemplatesHandler, batchHandler *handlers.BatchHandler, monitoringHandler *handlers.MonitoringHandler, quotasHandler *handlers.QuotasHandler, websocketHandler *handlers.WebSocketHandler, consoleHandler *handlers.ConsoleHandler, collaborationHandler *handlers.CollaborationHandler, integrationsHandler *handlers.IntegrationsHandler, loadBalancingHandler *handlers.LoadBalancingHandler, schedulingHandler *handlers.SchedulingHandler, securityHandler *handlers.SecurityHandler, templateVersioningHandler *handlers.TemplateVersioningHandler, setupHandler *handlers.SetupHandler, jwtManager *auth.JWTManager, userDB *db.UserDB, redisCache *cache.Cache, webhookSecret string) {
// SECURITY: Create authentication middleware
authMiddleware := auth.Middleware(jwtManager, userDB)
adminMiddleware := auth.RequireRole("admin")
Expand All @@ -372,6 +373,7 @@ func setupRoutes(router *gin.Engine, h *api.Handler, userHandler *handlers.UserH
authGroup := v1.Group("/auth")
{
authHandler.RegisterRoutes(authGroup)
setupHandler.RegisterRoutes(authGroup)
}

// PROTECTED ROUTES - Require authentication
Expand All @@ -392,8 +394,8 @@ func setupRoutes(router *gin.Engine, h *api.Handler, userHandler *handlers.UserH
sessions.PATCH("/:id/tags", cache.InvalidateCacheMiddleware(redisCache, cache.SessionPattern()), h.UpdateSessionTags)
sessions.GET("/:id/connect", h.ConnectSession)
sessions.POST("/:id/disconnect", h.DisconnectSession)
sessions.POST("/:id/heartbeat", h.SessionHeartbeat)

// NOTE: Session heartbeat is registered by ActivityHandler.RegisterRoutes()
// NOTE: Session recording is now handled by the streamspace-recording plugin
// Install it via: Admin → Plugins → streamspace-recording

Expand Down Expand Up @@ -590,30 +592,30 @@ func setupRoutes(router *gin.Engine, h *api.Handler, userHandler *handlers.UserH
templatesWrite.DELETE("/:id", cache.InvalidateCacheMiddleware(redisCache, cache.TemplatePattern()), h.DeleteTemplate)

// Template Versioning (operator only)
templatesWrite.POST("/:templateId/versions", templateVersioningHandler.CreateTemplateVersion)
templatesWrite.GET("/:templateId/versions", templateVersioningHandler.ListTemplateVersions)
templatesWrite.GET("/versions/:versionId", templateVersioningHandler.GetTemplateVersion)
templatesWrite.POST("/versions/:versionId/publish", templateVersioningHandler.PublishTemplateVersion)
templatesWrite.POST("/versions/:versionId/deprecate", templateVersioningHandler.DeprecateTemplateVersion)
templatesWrite.POST("/versions/:versionId/set-default", templateVersioningHandler.SetDefaultTemplateVersion)
templatesWrite.POST("/versions/:versionId/clone", templateVersioningHandler.CloneTemplateVersion)
templatesWrite.POST("/:id/versions", templateVersioningHandler.CreateTemplateVersion)
templatesWrite.GET("/:id/versions", templateVersioningHandler.ListTemplateVersions)
templatesWrite.GET("/:id/versions/:versionId", templateVersioningHandler.GetTemplateVersion)
templatesWrite.POST("/:id/versions/:versionId/publish", templateVersioningHandler.PublishTemplateVersion)
templatesWrite.POST("/:id/versions/:versionId/deprecate", templateVersioningHandler.DeprecateTemplateVersion)
templatesWrite.POST("/:id/versions/:versionId/set-default", templateVersioningHandler.SetDefaultTemplateVersion)
templatesWrite.POST("/:id/versions/:versionId/clone", templateVersioningHandler.CloneTemplateVersion)

// Template Testing (operator only)
templatesWrite.POST("/versions/:versionId/tests", templateVersioningHandler.CreateTemplateTest)
templatesWrite.GET("/versions/:versionId/tests", templateVersioningHandler.ListTemplateTests)
templatesWrite.PATCH("/tests/:testId", templateVersioningHandler.UpdateTemplateTestStatus)
templatesWrite.POST("/:id/versions/:versionId/tests", templateVersioningHandler.CreateTemplateTest)
templatesWrite.GET("/:id/versions/:versionId/tests", templateVersioningHandler.ListTemplateTests)
templatesWrite.PATCH("/:id/versions/:versionId/tests/:testId", templateVersioningHandler.UpdateTemplateTestStatus)

// Template Inheritance
templatesWrite.GET("/:templateId/inheritance", templateVersioningHandler.GetTemplateInheritance)
templatesWrite.GET("/:id/inheritance", templateVersioningHandler.GetTemplateInheritance)
}
}

// Catalog (read: all users, write: operators/admins)
// Catalog repositories (read: all users, write: operators/admins)
// NOTE: Template catalog routes are handled by CatalogHandler.RegisterRoutes()
catalog := protected.Group("/catalog")
{
// Cache catalog data for 10 minutes (changes on sync)
// Repository management
catalog.GET("/repositories", cache.CacheMiddleware(redisCache, 10*time.Minute), h.ListRepositories)
catalog.GET("/templates", cache.CacheMiddleware(redisCache, 10*time.Minute), h.BrowseCatalog)

// Write operations require operator role
catalogWrite := catalog.Group("")
Expand Down Expand Up @@ -702,7 +704,7 @@ func setupRoutes(router *gin.Engine, h *api.Handler, userHandler *handlers.UserH
}

// Session activity recording and queries
sessionActivity := protected.Group("/sessions/:sessionId/activity")
sessionActivity := protected.Group("/sessions/:id/activity")
{
// Log new activity event (for internal API use)
sessionActivity.POST("/log", sessionActivityHandler.LogActivityEvent)
Expand Down
142 changes: 138 additions & 4 deletions api/internal/db/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,14 @@ package db
import (
"database/sql"
"fmt"
"log"
"net"
"os"
"regexp"
"strconv"
"strings"

"golang.org/x/crypto/bcrypt"
_ "github.com/lib/pq"
)

Expand Down Expand Up @@ -397,8 +400,8 @@ func (d *Database) Migrate() error {
`CREATE INDEX IF NOT EXISTS idx_catalog_templates_category ON catalog_templates(category)`,
`CREATE INDEX IF NOT EXISTS idx_catalog_templates_app_type ON catalog_templates(app_type)`,

// Template versions (track template version history)
`CREATE TABLE IF NOT EXISTS template_versions (
// Catalog template versions (track version history from repositories)
`CREATE TABLE IF NOT EXISTS catalog_template_versions (
id SERIAL PRIMARY KEY,
template_id INT REFERENCES catalog_templates(id) ON DELETE CASCADE,
version VARCHAR(50) NOT NULL,
Expand Down Expand Up @@ -1086,14 +1089,17 @@ func (d *Database) Migrate() error {
max_memory INT,
max_storage INT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, COALESCE(team_id, ''))
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,

// Create indexes for resource quotas
`CREATE INDEX IF NOT EXISTS idx_resource_quotas_user_id ON resource_quotas(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_resource_quotas_team_id ON resource_quotas(team_id)`,

// Unique constraint for user quotas (handle NULL team_id with partial indexes)
`CREATE UNIQUE INDEX IF NOT EXISTS idx_resource_quotas_user_team ON resource_quotas(user_id, team_id) WHERE team_id IS NOT NULL`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_resource_quotas_user_only ON resource_quotas(user_id) WHERE team_id IS NULL`,

// Quota policies table (defines quota enforcement rules)
`CREATE TABLE IF NOT EXISTS quota_policies (
id VARCHAR(255) PRIMARY KEY,
Expand Down Expand Up @@ -1960,5 +1966,133 @@ func (d *Database) Migrate() error {
}
}

// After migrations, ensure admin password is configured
if err := d.ensureAdminPassword(); err != nil {
return fmt.Errorf("failed to configure admin password: %w", err)
}

// Check for password reset request
if err := d.checkPasswordReset(); err != nil {
return fmt.Errorf("failed to process password reset: %w", err)
}

return nil
}

// ensureAdminPassword configures the admin password using multiple fallback methods
// Priority order:
// 1. ADMIN_PASSWORD environment variable (Kubernetes Secret or manual)
// 2. Leave NULL - enables setup wizard mode
func (d *Database) ensureAdminPassword() error {
// Check if admin user exists and has a password
var passwordHash sql.NullString
err := d.db.QueryRow("SELECT password_hash FROM users WHERE id = 'admin'").Scan(&passwordHash)
if err != nil {
// Admin user doesn't exist yet, skip (will be created by migration)
if err == sql.ErrNoRows {
return nil
}
return fmt.Errorf("failed to check admin user: %w", err)
}

// Admin already has a password - don't override
if passwordHash.Valid && passwordHash.String != "" {
log.Println("✓ Admin user already has a password configured")
return nil
}

// Priority 1: Check ADMIN_PASSWORD environment variable
password := os.Getenv("ADMIN_PASSWORD")
if password != "" {
log.Println("🔑 Using admin password from ADMIN_PASSWORD environment variable")

// Validate password strength
if len(password) < 8 {
return fmt.Errorf("ADMIN_PASSWORD must be at least 8 characters long")
}

// Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("failed to hash admin password: %w", err)
}

// Update admin user
_, err = d.db.Exec("UPDATE users SET password_hash = $1, updated_at = CURRENT_TIMESTAMP WHERE id = 'admin'", string(hashedPassword))
if err != nil {
return fmt.Errorf("failed to set admin password: %w", err)
}

log.Println("✓ Admin password configured successfully from environment variable")
return nil
}

// Priority 2: No password - enable setup wizard mode
log.Println("⚠️ ═══════════════════════════════════════════════════════════════")
log.Println("⚠️ ADMIN USER HAS NO PASSWORD SET!")
log.Println("⚠️ ═══════════════════════════════════════════════════════════════")
log.Println("⚠️ ")
log.Println("⚠️ The admin account requires password configuration.")
log.Println("⚠️ ")
log.Println("⚠️ Setup wizard mode is ENABLED at: /api/v1/auth/setup")
log.Println("⚠️ ")
log.Println("⚠️ Alternative methods:")
log.Println("⚠️ 1. Set ADMIN_PASSWORD environment variable")
log.Println("⚠️ 2. Use the setup wizard in your browser")
log.Println("⚠️ 3. Check Helm chart for auto-generated credentials")
log.Println("⚠️ ")
log.Println("⚠️ ═══════════════════════════════════════════════════════════════")

return nil // Not an error, just informational
}

// checkPasswordReset checks for ADMIN_PASSWORD_RESET environment variable
// and resets the admin password if set. This is for account recovery.
func (d *Database) checkPasswordReset() error {
resetPassword := os.Getenv("ADMIN_PASSWORD_RESET")
if resetPassword == "" {
return nil // No reset requested
}

log.Println("⚠️ ═══════════════════════════════════════════════════════════════")
log.Println("⚠️ ADMIN PASSWORD RESET DETECTED!")
log.Println("⚠️ ═══════════════════════════════════════════════════════════════")

// Validate password strength
if len(resetPassword) < 8 {
return fmt.Errorf("ADMIN_PASSWORD_RESET must be at least 8 characters long")
}

// Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(resetPassword), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("failed to hash reset password: %w", err)
}

// Update admin password
result, err := d.db.Exec("UPDATE users SET password_hash = $1, updated_at = CURRENT_TIMESTAMP WHERE id = 'admin'", string(hashedPassword))
if err != nil {
return fmt.Errorf("failed to reset admin password: %w", err)
}

rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to check reset result: %w", err)
}

if rowsAffected == 0 {
log.Println("⚠️ Admin user not found - password reset failed")
return fmt.Errorf("admin user not found")
}

log.Println("✓ Admin password RESET successfully!")
log.Println("⚠️ ")
log.Println("⚠️ NEXT STEPS:")
log.Println("⚠️ 1. Remove ADMIN_PASSWORD_RESET environment variable")
log.Println("⚠️ 2. Restart the API deployment")
log.Println("⚠️ 3. Log in with the new password")
log.Println("⚠️ ")
log.Println("⚠️ ═══════════════════════════════════════════════════════════════")

return nil
}
4 changes: 2 additions & 2 deletions api/internal/handlers/sessionactivity.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ func (h *SessionActivityHandler) LogActivityEvent(c *gin.Context) {
// GetSessionActivity returns activity log for a specific session
func (h *SessionActivityHandler) GetSessionActivity(c *gin.Context) {
ctx := context.Background()
sessionID := c.Param("sessionId")
sessionID := c.Param("id")

// Pagination
limit := 100
Expand Down Expand Up @@ -397,7 +397,7 @@ func (h *SessionActivityHandler) GetActivityStats(c *gin.Context) {
// GetSessionTimeline returns a timeline view of session activity
func (h *SessionActivityHandler) GetSessionTimeline(c *gin.Context) {
ctx := context.Background()
sessionID := c.Param("sessionId")
sessionID := c.Param("id")

query := `
SELECT id, event_type, event_category, description,
Expand Down
Loading
Loading