diff --git a/.env.example b/.env.example index ab5fbe7..233ce5f 100644 --- a/.env.example +++ b/.env.example @@ -64,6 +64,12 @@ BETTER_AUTH_PROXY_URL=http://127.0.0.1:3001 # Extra trusted origins (comma-separated). Same-origin traffic is trusted # automatically, so this only needs entries for cross-origin callers. BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000,http://localhost:8082 + +# Platform owner bootstrap — creates/promotes the admin account on startup. +# Keep the real values in a local untracked file, never in committed env files. +ADMIN_EMAIL=info@tdvorak.dev +ADMIN_PASSWORD= +ADMIN_NAME=Platform Admin # Optional explicit auth DB settings (recommended when password contains URL special chars) DB_HOST=postgres DB_PORT=5432 diff --git a/app/backend/cmd/server/main.go b/app/backend/cmd/server/main.go index 7c10fcb..46304fd 100644 --- a/app/backend/cmd/server/main.go +++ b/app/backend/cmd/server/main.go @@ -86,6 +86,9 @@ func main() { }() } + // Provision the platform owner account (idempotent, non-fatal). + api.EnsureAdminAccount(context.Background(), db, cfg) + // Setup Gin router if cfg.IsProduction() { gin.SetMode(gin.ReleaseMode) diff --git a/app/backend/internal/api/admin.go b/app/backend/internal/api/admin.go new file mode 100644 index 0000000..51cebf2 --- /dev/null +++ b/app/backend/internal/api/admin.go @@ -0,0 +1,295 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "log" + "net/http" + "os" + "strings" + "time" + + "containr/internal/config" + "containr/internal/database" + "containr/internal/database/sqlcdb" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" +) + +// --------------------------------------------------------------------------- +// Admin account bootstrap +// --------------------------------------------------------------------------- + +// EnsureAdminAccount provisions the platform owner account from +// ADMIN_EMAIL/ADMIN_PASSWORD (optional ADMIN_NAME). It is idempotent: existing +// users are simply promoted to admin. Both the local users row (legacy JWT +// login) and the Better Auth account (cookie sessions) are created so either +// sign-in path works. Failures are logged, never fatal — the platform must +// boot even when auth is misconfigured. +func EnsureAdminAccount(ctx context.Context, db *database.DB, cfg *config.Config) { + email := strings.ToLower(strings.TrimSpace(cfg.AdminEmail)) + password := cfg.AdminPassword + if email == "" || password == "" { + return + } + if db == nil || db.DB == nil { + log.Println("Admin bootstrap skipped: database unavailable") + return + } + name := strings.TrimSpace(cfg.AdminName) + if name == "" { + name = "Platform Admin" + } + + // Local users row (owns resources, is_admin flag, legacy login hash). + hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + log.Printf("Admin bootstrap failed: %v", err) + return + } + + var userID string + err = db.QueryRowContext(ctx, ` + INSERT INTO users (email, password_hash, name, is_admin) + VALUES ($1, $2, $3, true) + ON CONFLICT (email) DO UPDATE + SET is_admin = true, updated_at = NOW() + RETURNING id + `, email, string(hashed), name).Scan(&userID) + if err != nil { + log.Printf("Admin bootstrap failed to upsert user %s: %v", email, err) + return + } + + // Better Auth account (cookie-session sign-in). Duplicate sign-up returns + // an error which we treat as "already provisioned". + if err := ensureBetterAuthUser(ctx, name, email, password); err != nil { + if errors.Is(err, errBetterAuthUserExists) { + log.Printf("Admin account %s already exists in auth service; ensured admin flag", email) + } else { + log.Printf("Warning: admin bootstrap could not create auth user for %s: %v", email, err) + } + return + } + log.Printf("Admin account provisioned: %s", email) +} + +// ensureBetterAuthUser calls the Better Auth sign-up endpoint directly. Unlike +// createBetterAuthUser it runs outside a request context. +func ensureBetterAuthUser(ctx context.Context, name, email, password string) error { + endpoint, err := betterAuthSignUpURL() + if err != nil { + return err + } + + body, err := json.Marshal(gin.H{"name": name, "email": email, "password": password}) + if err != nil { + return err + } + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json") + if token := strings.TrimSpace(os.Getenv("BETTER_AUTH_INTERNAL_TOKEN")); token != "" { + request.Header.Set("X-Containr-Auth-Internal", token) + } + + response, err := (&http.Client{Timeout: 10 * time.Second}).Do(request) + if err != nil { + return err + } + defer response.Body.Close() + + if response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices { + return nil + } + + var payload map[string]interface{} + _ = json.NewDecoder(response.Body).Decode(&payload) + message := "" + if raw, ok := payload["message"].(string); ok { + message = strings.ToLower(raw) + } else if raw, ok := payload["error"].(string); ok { + message = strings.ToLower(raw) + } + if strings.Contains(message, "exist") || strings.Contains(message, "already") { + return errBetterAuthUserExists + } + if message == "" { + message = http.StatusText(response.StatusCode) + } + return errors.New(message) +} + +// --------------------------------------------------------------------------- +// Admin API +// --------------------------------------------------------------------------- + +type adminUserRow struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + AvatarURL string `json:"avatar_url"` + IsAdmin bool `json:"is_admin"` + CreatedAt string `json:"created_at"` +} + +func handleAdminOverview(c *gin.Context) { + db := c.MustGet("db").(*database.DB) + queries := sqlcdb.New(db.DB) + ctx := c.Request.Context() + + count := func(query string) int { + var n int + if err := db.QueryRowContext(ctx, query).Scan(&n); err != nil { + return 0 + } + return n + } + + pendingRows, err := queries.ListPendingProjects(ctx) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list pending projects"}) + return + } + pending := make([]Project, 0, len(pendingRows)) + for _, row := range pendingRows { + pending = append(pending, mapSQLCProject(row)) + } + + c.JSON(http.StatusOK, gin.H{ + "stats": gin.H{ + "users": count(`SELECT COUNT(*) FROM users`), + "projects": count(`SELECT COUNT(*) FROM projects`), + "pending_projects": count(`SELECT COUNT(*) FROM projects WHERE is_approved = false`), + "services": count(`SELECT COUNT(*) FROM services`), + "running_services": count(`SELECT COUNT(*) FROM services WHERE status = 'running'`), + "databases": count(`SELECT COUNT(*) FROM database_services`), + "agents": count(`SELECT COUNT(*) FROM node_agents`), + "templates": count(`SELECT COUNT(*) FROM service_templates`), + "user_templates": count(`SELECT COUNT(*) FROM service_templates WHERE is_official = false`), + "cron_jobs": count(`SELECT COUNT(*) FROM cron_jobs`), + "deployments_total": count(`SELECT COUNT(*) FROM deployments`), + }, + "pending_projects": pending, + }) +} + +func handleAdminListUsers(c *gin.Context) { + db := c.MustGet("db").(*database.DB) + + rows, err := db.QueryContext(c.Request.Context(), ` + SELECT id, email, name, COALESCE(avatar_url, ''), is_admin, COALESCE(to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), '') + FROM users + ORDER BY created_at ASC + `) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list users"}) + return + } + defer rows.Close() + + users := make([]adminUserRow, 0) + for rows.Next() { + var u adminUserRow + if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.AvatarURL, &u.IsAdmin, &u.CreatedAt); err != nil { + continue + } + users = append(users, u) + } + c.JSON(http.StatusOK, gin.H{"users": users}) +} + +func handleAdminSetUserAdmin(c *gin.Context) { + db := c.MustGet("db").(*database.DB) + targetID := strings.TrimSpace(c.Param("id")) + if _, err := uuid.Parse(targetID); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"}) + return + } + + var req struct { + IsAdmin bool `json:"is_admin"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + callerID := optionalUserID(c) + if callerID == targetID && !req.IsAdmin { + c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot revoke your own admin access"}) + return + } + + result, err := db.ExecContext(c.Request.Context(), + `UPDATE users SET is_admin = $1, updated_at = NOW() WHERE id = $2`, + req.IsAdmin, targetID, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user"}) + return + } + if n, _ := result.RowsAffected(); n == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + return + } + + LogAudit(callerID, "user", targetID, "set_admin", map[string]interface{}{"is_admin": req.IsAdmin}) + c.JSON(http.StatusOK, gin.H{"message": "User updated"}) +} + +func handleAdminSetProjectApproval(c *gin.Context) { + db := c.MustGet("db").(*database.DB) + queries := sqlcdb.New(db.DB) + + projectID, ok := parseProjectIDParam(c) + if !ok { + return + } + + var req struct { + IsApproved bool `json:"is_approved"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + rows, err := queries.SetProjectApproved(c.Request.Context(), sqlcdb.SetProjectApprovedParams{ + IsApproved: req.IsApproved, + ProjectID: projectID, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update project"}) + return + } + if rows == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) + return + } + + action := "unapprove" + if req.IsApproved { + action = "approve" + } + LogAudit(optionalUserID(c), "project", projectID.String(), action, nil) + + row, err := queries.GetProjectByIDForUser(c.Request.Context(), sqlcdb.GetProjectByIDForUserParams{ + ProjectID: projectID, + UserID: optionalUserUUID(c), + IsAdmin: true, + }) + if err != nil { + c.JSON(http.StatusOK, gin.H{"message": "Project updated"}) + return + } + c.JSON(http.StatusOK, gin.H{"project": mapSQLCProject(row)}) +} diff --git a/app/backend/internal/api/agents.go b/app/backend/internal/api/agents.go index ac82b79..fbe58b9 100644 --- a/app/backend/internal/api/agents.go +++ b/app/backend/internal/api/agents.go @@ -1217,8 +1217,10 @@ func (h *NodeAgentHandler) SetupPublicRoutes(router *gin.RouterGroup) { } } -// SetupRoutes registers authenticated dashboard/control agent routes. -func (h *NodeAgentHandler) SetupRoutes(router *gin.RouterGroup) { +// SetupAdminRoutes registers dashboard/control agent routes. Command +// history and container details can leak sensitive output, so these stay +// admin-only rather than joining the public read group. +func (h *NodeAgentHandler) SetupAdminRoutes(router *gin.RouterGroup) { tokens := router.Group("/agent-tokens") { tokens.POST("", h.CreateAgentToken) diff --git a/app/backend/internal/api/context_user.go b/app/backend/internal/api/context_user.go index ba67391..3a162fd 100644 --- a/app/backend/internal/api/context_user.go +++ b/app/backend/internal/api/context_user.go @@ -3,7 +3,10 @@ package api import ( "net/http" + "containr/internal/database" + "github.com/gin-gonic/gin" + "github.com/google/uuid" ) func requireAuthenticatedUserID(c *gin.Context) (string, bool) { @@ -21,3 +24,107 @@ func requireAuthenticatedUserID(c *gin.Context) (string, bool) { return userID, true } + +// optionalUserID returns the resolved user id or "" for anonymous callers on +// OptionalAuth routes. Never writes a response. +func optionalUserID(c *gin.Context) string { + userIDValue, exists := c.Get("user_id") + if !exists { + return "" + } + userID, _ := userIDValue.(string) + return userID +} + +// optionalUserUUID is the uuid variant of optionalUserID; anonymous callers +// get uuid.Nil, which matches no owner_id and therefore only sees approved +// public resources. +func optionalUserUUID(c *gin.Context) uuid.UUID { + userID := optionalUserID(c) + parsed, err := uuid.Parse(userID) + if err != nil { + return uuid.Nil + } + return parsed +} + +// contextIsAdmin reports whether the resolved caller is a platform admin. +func contextIsAdmin(c *gin.Context) bool { + return c.GetBool("is_admin") +} + +// projectReadAccess reports (exists, allowedToRead). Approved projects are +// public; unapproved ones are visible to the owner, project members, and +// platform admins only. +func projectReadAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bool, bool) { + var isApproved bool + var ownerID string + err := db.QueryRow( + `SELECT is_approved, owner_id::text FROM projects WHERE id = $1`, + projectID, + ).Scan(&isApproved, &ownerID) + if err != nil { + return false, false + } + if isApproved || contextIsAdmin(c) { + return true, true + } + userID := optionalUserID(c) + if userID == "" { + return true, false + } + if ownerID == userID { + return true, true + } + var member bool + if err := db.QueryRow( + `SELECT EXISTS(SELECT 1 FROM project_members WHERE project_id = $1 AND user_id = $2)`, + projectID, userID, + ).Scan(&member); err == nil && member { + return true, true + } + return true, false +} + +// projectManageAccess reports (exists, allowedToOperate). Unlike +// projectReadAccess, approval does NOT grant access — only the owner, +// project members, and platform admins may reach secret-adjacent data +// such as variables and runtime logs. +func projectManageAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bool, bool) { + var ownerID string + err := db.QueryRow( + `SELECT owner_id::text FROM projects WHERE id = $1`, + projectID, + ).Scan(&ownerID) + if err != nil { + return false, false + } + if contextIsAdmin(c) { + return true, true + } + userID := optionalUserID(c) + if userID == "" { + return true, false + } + if ownerID == userID { + return true, true + } + var member bool + if err := db.QueryRow( + `SELECT EXISTS(SELECT 1 FROM project_members WHERE project_id = $1 AND user_id = $2)`, + projectID, userID, + ).Scan(&member); err == nil && member { + return true, true + } + return true, false +} + +// serviceProjectID resolves the project that owns a service. +func serviceProjectID(db *database.DB, serviceID uuid.UUID) (uuid.UUID, bool) { + var projectID uuid.UUID + err := db.QueryRow(`SELECT project_id FROM services WHERE id = $1`, serviceID).Scan(&projectID) + if err != nil { + return uuid.Nil, false + } + return projectID, true +} diff --git a/app/backend/internal/api/cron.go b/app/backend/internal/api/cron.go index 7f5f7f6..0b15f81 100644 --- a/app/backend/internal/api/cron.go +++ b/app/backend/internal/api/cron.go @@ -65,19 +65,34 @@ type UpdateCronJobRequest struct { Retention int `json:"retention"` } +// cronJobProjectID resolves the project a cron job belongs to. +func cronJobProjectID(db *database.DB, jobID string) (uuid.UUID, bool) { + var projectID uuid.UUID + err := db.QueryRow( + `SELECT cj.project_id FROM cron_jobs cj WHERE cj.id = $1`, + jobID, + ).Scan(&projectID) + if err != nil { + return uuid.Nil, false + } + return projectID, true +} + func handleGetCronJobs(c *gin.Context) { db := c.MustGet("db").(*database.DB) - userID := c.MustGet("user_id").(string) + userID := optionalUserUUID(c) + isAdmin := contextIsAdmin(c) projectID := c.Query("project_id") serviceID := c.Query("service_id") - query := `SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone, - cj.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output, + query := `SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone, + cj.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output, cj.retention, cj.created_at, cj.updated_at FROM cron_jobs cj JOIN projects p ON cj.project_id = p.id - WHERE p.owner_id = $1` - args := []interface{}{userID} + WHERE (p.is_approved OR p.owner_id = $1 OR $2::bool + OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1))` + args := []interface{}{userID, isAdmin} if projectID != "" { args = append(args, projectID) @@ -130,7 +145,7 @@ func handleCreateCronJob(c *gin.Context) { req.ServiceID, ).Scan(&ownerCheck) - if err != nil || ownerCheck != userID { + if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } @@ -184,30 +199,32 @@ func handleCreateCronJob(c *gin.Context) { func handleGetCronJob(c *gin.Context) { db := c.MustGet("db").(*database.DB) - userID := c.MustGet("user_id").(string) jobID := c.Param("id") var job CronJob - var ownerCheck string err := db.QueryRow( - `SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone, - cj.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output, - cj.retention, cj.created_at, cj.updated_at, p.owner_id + `SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone, + cj.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output, + cj.retention, cj.created_at, cj.updated_at FROM cron_jobs cj - JOIN projects p ON cj.project_id = p.id WHERE cj.id = $1`, jobID, ).Scan(&job.ID, &job.ProjectID, &job.ServiceID, &job.Name, &job.Schedule, &job.Timezone, &job.Enabled, &job.LastRunAt, &job.NextRunAt, &job.LastStatus, &job.LastOutput, - &job.Retention, &job.CreatedAt, &job.UpdatedAt, &ownerCheck) + &job.Retention, &job.CreatedAt, &job.UpdatedAt) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"}) return } - if ownerCheck != userID { - c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) + projectID, found := cronJobProjectID(db, jobID) + if !found { + c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"}) + return + } + if _, allowed := projectReadAccess(c, db, projectID); !allowed { + c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"}) return } @@ -233,7 +250,7 @@ func handleUpdateCronJob(c *gin.Context) { jobID, ).Scan(&ownerCheck) - if err != nil || ownerCheck != userID { + if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } @@ -303,7 +320,7 @@ func handleDeleteCronJob(c *gin.Context) { jobID, ).Scan(&ownerCheck) - if err != nil || ownerCheck != userID { + if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } @@ -321,19 +338,15 @@ func handleDeleteCronJob(c *gin.Context) { func handleGetCronExecutions(c *gin.Context) { db := c.MustGet("db").(*database.DB) - userID := c.MustGet("user_id").(string) jobID := c.Param("id") - var ownerCheck string - err := db.QueryRow( - `SELECT p.owner_id FROM cron_jobs cj - JOIN projects p ON cj.project_id = p.id - WHERE cj.id = $1`, - jobID, - ).Scan(&ownerCheck) - - if err != nil || ownerCheck != userID { - c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) + projectID, found := cronJobProjectID(db, jobID) + if !found { + c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"}) + return + } + if _, allowed := projectReadAccess(c, db, projectID); !allowed { + c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"}) return } @@ -380,7 +393,7 @@ func handleTriggerCronJob(c *gin.Context) { jobID, ).Scan(&job.ServiceID, &job.Command, &job.Schedule, &job.Timezone, &job.Retention, &ownerCheck) - if err != nil || ownerCheck != userID { + if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } diff --git a/app/backend/internal/api/databases.go b/app/backend/internal/api/databases.go index 1f0ea0b..0fd2e85 100644 --- a/app/backend/internal/api/databases.go +++ b/app/backend/internal/api/databases.go @@ -41,6 +41,7 @@ const ( // DatabaseService represents a managed database service type DatabaseService struct { ID string `json:"id" db:"id"` + OwnerID string `json:"-" db:"user_id"` Name string `json:"name" db:"name"` Type string `json:"type" db:"type"` // postgresql, redis, mysql, mariadb, mongodb, clickhouse, dragonfly Status string `json:"status" db:"status"` // running, stopped, building, error @@ -168,13 +169,14 @@ func NewDatabaseHandler(db *sql.DB, dockerClient *docker.Client) *DatabaseHandle } } -// GetDatabases returns all database services for a user +// GetDatabases returns database services. The platform is public-read: every +// caller sees the fleet, but connection URLs (which may embed credentials) +// are only exposed to admins. func (h *DatabaseHandler) GetDatabases(c *gin.Context) { - userID, ok := requireAuthenticatedUserID(c) - if !ok { - return - } - rows, err := h.queries.ListDatabaseServicesByUser(c.Request.Context(), userID) + userID := optionalUserID(c) + isAdmin := contextIsAdmin(c) + + rows, err := h.queries.ListAllDatabaseServices(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch databases"}) return @@ -182,17 +184,23 @@ func (h *DatabaseHandler) GetDatabases(c *gin.Context) { databases := make([]DatabaseService, 0, len(rows)) for _, row := range rows { - db := mapDatabaseServiceListRow(row) + db := mapDatabaseServiceRow(row) db = h.reconcileManagedDatabaseState(c.Request.Context(), db) db.Metrics = h.resolveDatabaseMetrics(c.Request.Context(), db) - if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), userID, db); err == nil { - db.Backups = backupConfig + privileged := isAdmin || (userID != "" && db.OwnerID == userID) + if privileged { + if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), db.OwnerID, db); err == nil { + db.Backups = backupConfig + } else { + db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}} + } + db.Settings = h.generateMockSettings() + db.ConnectionURL = h.resolveConnectionURL(db) } else { - db.Backups = h.generateMockBackupConfig() + db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}} + db.ConnectionURL = "" } - db.Settings = h.generateMockSettings() - db.ConnectionURL = h.resolveConnectionURL(db) databases = append(databases, db) } @@ -202,15 +210,11 @@ func (h *DatabaseHandler) GetDatabases(c *gin.Context) { // GetDatabase returns a specific database service func (h *DatabaseHandler) GetDatabase(c *gin.Context) { - userID, ok := requireAuthenticatedUserID(c) - if !ok { - return - } + userID := optionalUserID(c) + isAdmin := contextIsAdmin(c) + databaseID := c.Param("id") - row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{ - ID: databaseID, - UserID: userID, - }) + row, err := h.queries.GetDatabaseServiceByID(c.Request.Context(), databaseID) if err != nil { if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"}) @@ -219,21 +223,40 @@ func (h *DatabaseHandler) GetDatabase(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch database"}) return } - db := mapDatabaseServiceGetRow(row) + db := mapDatabaseServiceRow(row) db = h.reconcileManagedDatabaseState(c.Request.Context(), db) db.Metrics = h.resolveDatabaseMetrics(c.Request.Context(), db) - if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), userID, db); err == nil { - db.Backups = backupConfig + privileged := isAdmin || (userID != "" && db.OwnerID == userID) + if privileged { + if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), db.OwnerID, db); err == nil { + db.Backups = backupConfig + } else { + db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}} + } + db.Settings = h.generateMockSettings() + db.ConnectionURL = h.resolveConnectionURL(db) } else { - db.Backups = h.generateMockBackupConfig() + db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}} + db.ConnectionURL = "" } - db.Settings = h.generateMockSettings() - db.ConnectionURL = h.resolveConnectionURL(db) c.JSON(http.StatusOK, db) } +// effectiveUserID returns the user scope for row-level queries. Admins act +// on behalf of the row's owner so management works across accounts. +func (h *DatabaseHandler) effectiveUserID(c *gin.Context, userID, databaseID string) string { + if !contextIsAdmin(c) || databaseID == "" { + return userID + } + owner, err := h.queries.GetDatabaseServiceOwnerID(c.Request.Context(), databaseID) + if err == nil && owner != "" { + return owner + } + return userID +} + // CreateDatabase creates a new database service func (h *DatabaseHandler) CreateDatabase(c *gin.Context) { userID, ok := requireAuthenticatedUserID(c) @@ -357,6 +380,7 @@ func (h *DatabaseHandler) UpdateDatabase(c *gin.Context) { return } databaseID := c.Param("id") + userID = h.effectiveUserID(c, userID, databaseID) var req DatabaseUpdateRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -440,6 +464,7 @@ func (h *DatabaseHandler) DeleteDatabase(c *gin.Context) { return } databaseID := c.Param("id") + userID = h.effectiveUserID(c, userID, databaseID) exists, err := h.queries.DatabaseServiceExistsByIDAndUser(c.Request.Context(), sqlcdb.DatabaseServiceExistsByIDAndUserParams{ ID: databaseID, @@ -481,6 +506,7 @@ func (h *DatabaseHandler) PerformDatabaseAction(c *gin.Context) { return } databaseID := c.Param("id") + userID = h.effectiveUserID(c, userID, databaseID) var req DatabaseActionRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -574,6 +600,7 @@ func (h *DatabaseHandler) CreateBackup(c *gin.Context) { return } databaseID := c.Param("id") + userID = h.effectiveUserID(c, userID, databaseID) row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{ ID: databaseID, @@ -623,6 +650,7 @@ func (h *DatabaseHandler) RestoreBackup(c *gin.Context) { return } databaseID := c.Param("id") + userID = h.effectiveUserID(c, userID, databaseID) var req DatabaseRestoreRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -684,6 +712,7 @@ func (h *DatabaseHandler) DownloadBackup(c *gin.Context) { return } databaseID := c.Param("id") + userID = h.effectiveUserID(c, userID, databaseID) backupID := c.Param("bid") backup, err := h.queries.GetDatabaseBackupByIDAndDatabaseAndUser(c.Request.Context(), sqlcdb.GetDatabaseBackupByIDAndDatabaseAndUserParams{ @@ -904,35 +933,6 @@ func (h *DatabaseHandler) resolveBackupConfig(ctx context.Context, userID string }, nil } -func (h *DatabaseHandler) generateMockBackupConfig() DatabaseBackupConfig { - return DatabaseBackupConfig{ - Enabled: true, - LastBackup: &time.Time{}, - Retention: 30, - NextBackup: &time.Time{}, - Backups: []DatabaseBackup{ - { - ID: "backup_1", - CreatedAt: time.Now().Add(-6 * time.Hour), - Size: "245 MB", - Status: "completed", - }, - { - ID: "backup_2", - CreatedAt: time.Now().Add(-24 * time.Hour), - Size: "238 MB", - Status: "completed", - }, - { - ID: "backup_3", - CreatedAt: time.Now().Add(-48 * time.Hour), - Size: "241 MB", - Status: "completed", - }, - }, - } -} - func (h *DatabaseHandler) generateMockSettings() DatabaseSettings { return DatabaseSettings{ MaxConnections: 100, @@ -1038,26 +1038,10 @@ func (h *DatabaseHandler) resolveManagedRuntimeStatus(ctx context.Context, db Da } } -func mapDatabaseServiceListRow(row sqlcdb.ListDatabaseServicesByUserRow) DatabaseService { - return DatabaseService{ - ID: row.ID, - Name: row.Name, - Type: row.Type, - Status: row.Status, - Version: row.Version, - Plan: row.Plan, - Region: row.Region, - BackupSchedule: databaseNullString(row.BackupSchedule), - NextBackupAt: databaseNullTimePtr(row.NextBackupAt), - ConnectionURL: databaseNullString(row.ConnectionUrl), - CreatedAt: databaseNullTime(row.CreatedAt), - UpdatedAt: databaseNullTime(row.UpdatedAt), - } -} - -func mapDatabaseServiceGetRow(row sqlcdb.GetDatabaseServiceByIDAndUserRow) DatabaseService { +func mapDatabaseServiceRow(row sqlcdb.DatabaseService) DatabaseService { return DatabaseService{ ID: row.ID, + OwnerID: row.UserID, Name: row.Name, Type: row.Type, Status: row.Status, diff --git a/app/backend/internal/api/deployments.go b/app/backend/internal/api/deployments.go index d63a372..bbb4c5a 100644 --- a/app/backend/internal/api/deployments.go +++ b/app/backend/internal/api/deployments.go @@ -64,36 +64,22 @@ func handleGetDeployments(c *gin.Context) { return } - userID, exists := c.Get("user_id") - if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + projectID, found := serviceProjectID(db.(*database.DB), serviceID) + if !found { + c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"}) return } - - var ownerCheck string - err = db.(*database.DB).QueryRow( - `SELECT p.owner_id FROM services s - JOIN projects p ON s.project_id = p.id - WHERE s.id = $1`, - serviceID, - ).Scan(&ownerCheck) - - if err != nil { + if _, allowed := projectReadAccess(c, db.(*database.DB), projectID); !allowed { c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"}) return } - if ownerCheck != userID.(string) { - c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) - return - } - rows, err := db.(*database.DB).Query( - `SELECT id, service_id, commit_hash, status, image_name, image_tag, + `SELECT id, service_id, commit_hash, status, image_name, image_tag, build_log, runtime_log, error, started_at, completed_at, created_at, updated_at - FROM deployments - WHERE service_id = $1 - ORDER BY created_at DESC + FROM deployments + WHERE service_id = $1 + ORDER BY created_at DESC LIMIT 50`, serviceID, ) @@ -143,11 +129,8 @@ func handleGetRecentDeployments(c *gin.Context) { return } - userID, exists := c.Get("user_id") - if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) - return - } + userID := optionalUserUUID(c) + isAdmin := contextIsAdmin(c) limit := 10 if v := c.Query("limit"); v != "" { @@ -162,10 +145,11 @@ func handleGetRecentDeployments(c *gin.Context) { FROM deployments d JOIN services s ON s.id = d.service_id JOIN projects p ON p.id = s.project_id - WHERE p.owner_id = $1 + WHERE p.is_approved OR p.owner_id = $1 OR $2::bool + OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1) ORDER BY d.created_at DESC - LIMIT $2`, - userID.(string), limit, + LIMIT $3`, + userID, isAdmin, limit, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"}) @@ -557,18 +541,12 @@ func handleGetDeployment(c *gin.Context) { return } - userID, exists := c.Get("user_id") - if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) - return - } - var d DeploymentModel - var ownerCheck string + var projectID uuid.UUID err = db.(*database.DB).QueryRow( - `SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag, - d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at, - d.created_at, d.updated_at, p.owner_id + `SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag, + d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at, + d.created_at, d.updated_at, p.id FROM deployments d JOIN services s ON d.service_id = s.id JOIN projects p ON s.project_id = p.id @@ -577,7 +555,7 @@ func handleGetDeployment(c *gin.Context) { ).Scan( &d.ID, &d.ServiceID, &d.CommitHash, &d.Status, &d.ImageName, &d.ImageTag, &d.BuildLog, &d.RuntimeLog, &d.Error, &d.StartedAt, &d.CompletedAt, - &d.CreatedAt, &d.UpdatedAt, &ownerCheck, + &d.CreatedAt, &d.UpdatedAt, &projectID, ) if err != nil { @@ -585,8 +563,8 @@ func handleGetDeployment(c *gin.Context) { return } - if ownerCheck != userID.(string) { - c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) + if _, allowed := projectReadAccess(c, db.(*database.DB), projectID); !allowed { + c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"}) return } @@ -639,7 +617,7 @@ func handleRollbackDeployment(c *gin.Context) { return } - if ownerCheck != userID.(string) { + if ownerCheck != userID.(string) && !contextIsAdmin(c) { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } diff --git a/app/backend/internal/api/exec.go b/app/backend/internal/api/exec.go index 09a125d..580135c 100644 --- a/app/backend/internal/api/exec.go +++ b/app/backend/internal/api/exec.go @@ -7,6 +7,7 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/google/uuid" "containr/internal/database" "containr/internal/docker" @@ -17,7 +18,6 @@ import ( // commands run as the container's default user with a 30s ceiling. func handleExecInService(c *gin.Context) { serviceID := c.Param("id") - userID := c.MustGet("user_id").(string) db := c.MustGet("db").(*database.DB) var req struct { @@ -33,17 +33,20 @@ func handleExecInService(c *gin.Context) { return } - var ownerID string - err := db.QueryRow( - `SELECT p.owner_id FROM services s - JOIN projects p ON p.id = s.project_id - WHERE s.id = $1`, - serviceID, - ).Scan(&ownerID) - if err != nil || ownerID != userID { + serviceUUID, err := uuid.Parse(serviceID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"}) + return + } + projectID, found := serviceProjectID(db, serviceUUID) + if !found { c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"}) return } + if _, allowed := projectManageAccess(c, db, projectID); !allowed { + c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) + return + } dockerClient, _ := c.Get("docker_client") dockerCli, _ := dockerClient.(*docker.Client) diff --git a/app/backend/internal/api/ha.go b/app/backend/internal/api/ha.go index f74fb28..1b67b93 100644 --- a/app/backend/internal/api/ha.go +++ b/app/backend/internal/api/ha.go @@ -25,43 +25,58 @@ func NewHAManager(haManager *ha.HighAvailabilityManager) *HAManager { } } -// RegisterRoutes registers HA routes -func (h *HAManager) RegisterRoutes(router *gin.RouterGroup) { +// RegisterReadRoutes registers public HA read endpoints. +func (h *HAManager) RegisterReadRoutes(router *gin.RouterGroup) { ha := router.Group("/ha") { ha.GET("/status", h.GetHAStatus) + + // Failover policies + ha.GET("/failover/policies", h.GetFailoverPolicies) + ha.GET("/failover/policies/:serviceId", h.GetFailoverPolicy) + + // Health checks + ha.GET("/health/checks", h.GetHealthChecks) + ha.GET("/health/checks/:checkId", h.GetHealthCheck) + ha.GET("/health/results", h.GetHealthResults) + + // Alerts + ha.GET("/alerts/rules", h.GetAlertRules) + ha.GET("/alerts/rules/:ruleId", h.GetAlertRule) + ha.GET("/alerts/active", h.GetActiveAlerts) + + // Notifiers + ha.GET("/notifiers", h.GetNotifiers) + ha.GET("/notifiers/:notifierId", h.GetNotifier) + } +} + +// RegisterAdminRoutes registers HA mutation endpoints. +func (h *HAManager) RegisterAdminRoutes(router *gin.RouterGroup) { + ha := router.Group("/ha") + { ha.POST("/enable", h.EnableHA) ha.POST("/disable", h.DisableHA) ha.POST("/failover", h.TriggerFailover) // Failover policies - ha.GET("/failover/policies", h.GetFailoverPolicies) ha.POST("/failover/policies", h.SetFailoverPolicy) - ha.GET("/failover/policies/:serviceId", h.GetFailoverPolicy) ha.PUT("/failover/policies/:serviceId", h.UpdateFailoverPolicy) ha.DELETE("/failover/policies/:serviceId", h.DeleteFailoverPolicy) // Health checks - ha.GET("/health/checks", h.GetHealthChecks) ha.POST("/health/checks", h.AddHealthCheck) - ha.GET("/health/checks/:checkId", h.GetHealthCheck) ha.PUT("/health/checks/:checkId", h.UpdateHealthCheck) ha.DELETE("/health/checks/:checkId", h.DeleteHealthCheck) - ha.GET("/health/results", h.GetHealthResults) // Alerts - ha.GET("/alerts/rules", h.GetAlertRules) ha.POST("/alerts/rules", h.AddAlertRule) - ha.GET("/alerts/rules/:ruleId", h.GetAlertRule) ha.PUT("/alerts/rules/:ruleId", h.UpdateAlertRule) ha.DELETE("/alerts/rules/:ruleId", h.DeleteAlertRule) - ha.GET("/alerts/active", h.GetActiveAlerts) ha.POST("/alerts/:alertId/resolve", h.ResolveAlert) // Notifiers - ha.GET("/notifiers", h.GetNotifiers) ha.POST("/notifiers", h.AddNotifier) - ha.GET("/notifiers/:notifierId", h.GetNotifier) ha.DELETE("/notifiers/:notifierId", h.DeleteNotifier) } } diff --git a/app/backend/internal/api/logs.go b/app/backend/internal/api/logs.go index 6b2d587..c533922 100644 --- a/app/backend/internal/api/logs.go +++ b/app/backend/internal/api/logs.go @@ -37,27 +37,13 @@ func handleGetLogs(c *gin.Context) { return } - userID, exists := c.Get("user_id") - if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) - return - } - - var ownerCheck string - err = db.(*database.DB).QueryRow( - `SELECT p.owner_id FROM services s - JOIN projects p ON s.project_id = p.id - WHERE s.id = $1`, - serviceID, - ).Scan(&ownerCheck) - - if err != nil { + projectID, found := serviceProjectID(db.(*database.DB), serviceID) + if !found { c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"}) return } - - if ownerCheck != userID.(string) { - c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) + if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed { + c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"}) return } @@ -209,30 +195,24 @@ func handleGetDeploymentLogs(c *gin.Context) { return } - userID, exists := c.Get("user_id") - if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) - return - } - var buildLog, runtimeLog string - var ownerCheck string + var projectID uuid.UUID err = db.(*database.DB).QueryRow( - `SELECT d.build_log, d.runtime_log, p.owner_id + `SELECT d.build_log, d.runtime_log, p.id FROM deployments d JOIN services s ON d.service_id = s.id JOIN projects p ON s.project_id = p.id WHERE d.id = $1`, deploymentID, - ).Scan(&buildLog, &runtimeLog, &ownerCheck) + ).Scan(&buildLog, &runtimeLog, &projectID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"}) return } - if ownerCheck != userID.(string) { - c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) + if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed { + c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"}) return } diff --git a/app/backend/internal/api/metrics.go b/app/backend/internal/api/metrics.go index 4b8fd65..e2c9813 100644 --- a/app/backend/internal/api/metrics.go +++ b/app/backend/internal/api/metrics.go @@ -60,26 +60,19 @@ func handleGetServiceMetrics(c *gin.Context) { return } - userID, exists := c.Get("user_id") - if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) - return - } - - var ownerID string + var projectID uuid.UUID var status string err = db.QueryRow( - `SELECT p.owner_id, COALESCE(s.status, '') - FROM services s JOIN projects p ON s.project_id = p.id - WHERE s.id = $1`, + `SELECT s.project_id, COALESCE(s.status, '') + FROM services s WHERE s.id = $1`, serviceID, - ).Scan(&ownerID, &status) + ).Scan(&projectID, &status) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"}) return } - if ownerID != userID.(string) { - c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) + if _, allowed := projectReadAccess(c, db, projectID); !allowed { + c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"}) return } diff --git a/app/backend/internal/api/preview_environments.go b/app/backend/internal/api/preview_environments.go index e897255..8d4e325 100644 --- a/app/backend/internal/api/preview_environments.go +++ b/app/backend/internal/api/preview_environments.go @@ -69,31 +69,13 @@ func handleGetPreviewEnvironments(c *gin.Context) { return } - // Check if project exists and user has access - var project Project - err = db.(*database.DB).QueryRow( - "SELECT id, name, owner_id FROM projects WHERE id = $1", - projectID, - ).Scan(&project.ID, &project.Name, &project.OwnerID) - - if err != nil { + // Approved projects are public; unapproved ones need owner/member/admin. + projectExists, allowed := projectReadAccess(c, db.(*database.DB), projectID) + if !projectExists || !allowed { c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) return } - // Get user ID from JWT token (set by auth middleware) - userID, exists := c.Get("user_id") - if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) - return - } - - // Check if user owns the project - if project.OwnerID != userID.(string) { - c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) - return - } - // Get preview environments for the project with service info rows, err := db.(*database.DB).Query( `SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number, @@ -193,7 +175,7 @@ func handleCreatePreviewEnvironment(c *gin.Context) { } // Check if user owns the project - if project.OwnerID != userID.(string) { + if project.OwnerID != userID.(string) && !contextIsAdmin(c) { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } @@ -332,27 +314,22 @@ func handleGetPreviewEnvironment(c *gin.Context) { return } - // Get user ID from JWT token - userID, exists := c.Get("user_id") - if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) - return - } - - // Get preview environment with project ownership check + // Public when the parent project is approved; otherwise owner/member/admin. + userID := optionalUserUUID(c) var env PreviewEnvironment var serviceID sql.NullString var serviceName sql.NullString var serviceType sql.NullString err = db.(*database.DB).QueryRow( - `SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number, + `SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number, pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at, s.id as service_id, s.name as service_name, s.type as service_type FROM preview_environments pe LEFT JOIN services s ON pe.service_id = s.id JOIN projects p ON pe.project_id = p.id - WHERE pe.id = $1 AND p.owner_id = $2`, - envID, userID, + WHERE pe.id = $1 AND (p.is_approved OR p.owner_id = $2 OR $3::bool + OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2))`, + envID, userID, contextIsAdmin(c), ).Scan( &env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.PRNumber, &env.Environment, &env.Status, &env.URL, &env.ExpiresAt, &env.CreatedAt, &env.UpdatedAt, @@ -414,8 +391,8 @@ func handleUpdatePreviewEnvironment(c *gin.Context) { pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at FROM preview_environments pe JOIN projects p ON pe.project_id = p.id - WHERE pe.id = $1 AND p.owner_id = $2`, - envID, userID, + WHERE pe.id = $1 AND (p.owner_id = $2 OR $3::bool)`, + envID, userID, contextIsAdmin(c), ).Scan( &existingEnv.ID, &existingEnv.ProjectID, &existingEnv.ServiceID, &existingEnv.BranchName, &existingEnv.PRNumber, &existingEnv.Environment, &existingEnv.Status, &existingEnv.URL, @@ -498,7 +475,7 @@ func handleDeletePreviewEnvironment(c *gin.Context) { } // Check if user owns the project - if projectOwnerID != userID.(string) { + if projectOwnerID != userID.(string) && !contextIsAdmin(c) { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } @@ -568,8 +545,8 @@ func handlePromotePreviewEnvironment(c *gin.Context) { `SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.environment, pe.status FROM preview_environments pe JOIN projects p ON pe.project_id = p.id - WHERE pe.id = $1 AND p.owner_id = $2`, - envID, userID, + WHERE pe.id = $1 AND (p.owner_id = $2 OR $3::bool)`, + envID, userID, contextIsAdmin(c), ).Scan( &env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.Environment, &env.Status, ) @@ -665,8 +642,8 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) { `SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.environment FROM preview_environments pe JOIN projects p ON pe.project_id = p.id - WHERE p.owner_id = $1 AND pe.expires_at < NOW() AND pe.status != 'expired'`, - userID, + WHERE (p.owner_id = $1 OR $2::bool) AND pe.expires_at < NOW() AND pe.status != 'expired'`, + userID, contextIsAdmin(c), ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to find expired preview environments"}) diff --git a/app/backend/internal/api/production_behavior_test.go b/app/backend/internal/api/production_behavior_test.go index b05accf..36eca98 100644 --- a/app/backend/internal/api/production_behavior_test.go +++ b/app/backend/internal/api/production_behavior_test.go @@ -6,8 +6,6 @@ import ( "net/http/httptest" "testing" - "containr/internal/database" - "github.com/gin-gonic/gin" ) @@ -89,7 +87,7 @@ func TestRequireAuthenticatedUserIDRejectsMissingUserContext(t *testing.T) { } } -func TestRecentDeploymentsRequireAuthentication(t *testing.T) { +func TestRecentDeploymentsAllowAnonymousRead(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() router.GET("/api/v1/deployments", handleGetRecentDeployments) @@ -98,20 +96,9 @@ func TestRecentDeploymentsRequireAuthentication(t *testing.T) { rec := httptest.NewRecorder() router.ServeHTTP(rec, req) - // No db in context → dependency failure surfaces before auth. + // Deployments are publicly readable; with no db in context the handler + // fails at the dependency boundary, never at an auth check. if rec.Code != http.StatusInternalServerError { t.Fatalf("expected status %d, got %d", http.StatusInternalServerError, rec.Code) } - - // With db present but no user context → 401. - router2 := gin.New() - router2.GET("/api/v1/deployments", func(c *gin.Context) { - c.Set("db", &database.DB{}) - handleGetRecentDeployments(c) - }) - rec2 := httptest.NewRecorder() - router2.ServeHTTP(rec2, httptest.NewRequest(http.MethodGet, "/api/v1/deployments", nil)) - if rec2.Code != http.StatusUnauthorized { - t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec2.Code) - } } diff --git a/app/backend/internal/api/projects.go b/app/backend/internal/api/projects.go index 6665f09..f5eebc8 100644 --- a/app/backend/internal/api/projects.go +++ b/app/backend/internal/api/projects.go @@ -21,6 +21,7 @@ type Project struct { Name string `json:"name"` Description string `json:"description"` OwnerID string `json:"owner_id"` + IsApproved bool `json:"is_approved"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } @@ -48,10 +49,8 @@ type UpdateProjectRequest struct { } func handleGetProjects(c *gin.Context) { - userID, ok := requireAuthenticatedUserUUID(c) - if !ok { - return - } + userID := optionalUserUUID(c) + isAdmin := contextIsAdmin(c) db := c.MustGet("db").(*database.DB) queries := sqlcdb.New(db.DB) @@ -75,6 +74,7 @@ func handleGetProjects(c *gin.Context) { rows, err := queries.ListProjectsWithStatsByUser(c.Request.Context(), sqlcdb.ListProjectsWithStatsByUserParams{ UserID: userID, + IsAdmin: isAdmin, Search: searchParam, LimitCount: int32(limit), OffsetCount: int32(offset), @@ -90,8 +90,9 @@ func handleGetProjects(c *gin.Context) { } total, err := queries.CountProjectsByUser(c.Request.Context(), sqlcdb.CountProjectsByUserParams{ - UserID: userID, - Search: searchParam, + UserID: userID, + IsAdmin: isAdmin, + Search: searchParam, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Database count error"}) @@ -171,10 +172,7 @@ func handleCreateProject(c *gin.Context) { } func handleGetProject(c *gin.Context) { - userID, ok := requireAuthenticatedUserUUID(c) - if !ok { - return - } + userID := optionalUserUUID(c) db := c.MustGet("db").(*database.DB) queries := sqlcdb.New(db.DB) @@ -187,6 +185,7 @@ func handleGetProject(c *gin.Context) { projectRow, err := queries.GetProjectByIDForUser(c.Request.Context(), sqlcdb.GetProjectByIDForUserParams{ ProjectID: projectID, UserID: userID, + IsAdmin: contextIsAdmin(c), }) if errors.Is(err, sql.ErrNoRows) { c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) @@ -217,6 +216,7 @@ func handleUpdateProject(c *gin.Context) { role, err := queries.GetProjectRoleForUser(c.Request.Context(), sqlcdb.GetProjectRoleForUserParams{ ProjectID: projectID, UserID: userID, + IsAdmin: contextIsAdmin(c), }) if errors.Is(err, sql.ErrNoRows) || role == "" || role == "viewer" { c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"}) @@ -270,6 +270,7 @@ func handleUpdateProject(c *gin.Context) { projectRow, err := queries.GetProjectByIDForUser(c.Request.Context(), sqlcdb.GetProjectByIDForUserParams{ ProjectID: projectID, UserID: userID, + IsAdmin: contextIsAdmin(c), }) if errors.Is(err, sql.ErrNoRows) { c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) @@ -307,7 +308,7 @@ func handleDeleteProject(c *gin.Context) { return } - if ownerID != userID { + if ownerID != userID && !contextIsAdmin(c) { c.JSON(http.StatusForbidden, gin.H{"error": "Only project owners can delete projects"}) return } @@ -361,7 +362,7 @@ func parseProjectIDParam(c *gin.Context) (uuid.UUID, bool) { func mapProjectWithStatsRow(row sqlcdb.ListProjectsWithStatsByUserRow) ProjectWithStats { result := ProjectWithStats{ - Project: mapProjectFields(row.ID, row.Name, row.Description, row.OwnerID, row.CreatedAt, row.UpdatedAt), + Project: mapProjectFields(row.ID, row.Name, row.Description, row.OwnerID, row.IsApproved, row.CreatedAt, row.UpdatedAt), Stats: ProjectStats{ ServiceCount: int(row.ServiceCount), DeploymentCount: int(row.DeploymentCount), @@ -373,15 +374,16 @@ func mapProjectWithStatsRow(row sqlcdb.ListProjectsWithStatsByUserRow) ProjectWi } func mapSQLCProject(project sqlcdb.Project) Project { - return mapProjectFields(project.ID, project.Name, project.Description, project.OwnerID, project.CreatedAt, project.UpdatedAt) + return mapProjectFields(project.ID, project.Name, project.Description, project.OwnerID, project.IsApproved, project.CreatedAt, project.UpdatedAt) } -func mapProjectFields(id uuid.UUID, name string, description sql.NullString, ownerID uuid.UUID, createdAt, updatedAt sql.NullTime) Project { +func mapProjectFields(id uuid.UUID, name string, description sql.NullString, ownerID uuid.UUID, isApproved bool, createdAt, updatedAt sql.NullTime) Project { return Project{ ID: id.String(), Name: name, Description: nullableStringValue(description), OwnerID: ownerID.String(), + IsApproved: isApproved, CreatedAt: nullableTimestampString(createdAt), UpdatedAt: nullableTimestampString(updatedAt), } diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index 3cbb142..13e3be4 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -96,6 +96,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg c.Next() }) + // Seed the official service template catalog (idempotent upserts). + go SeedOfficialTemplates(context.Background(), db) + go func() { if err := haManager.Start(context.Background()); err != nil { log.Printf("HA manager exited: %v", err) @@ -136,7 +139,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg if redis == nil { redisStatus = "unhealthy" - checks["redis"] = redisStatus + checks["redis"] = "unhealthy" checks["redisError"] = "redis client not initialized" overallStatus = "degraded" statusCode = http.StatusServiceUnavailable @@ -177,163 +180,182 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg public.POST("/auth/register", handleRegister) } - // Protected routes (authentication required) - protected := v1.Group("/") - protected.Use(middleware.Auth(cfg.JWTSecret)) + // Read routes — public browsing. OptionalAuth resolves a session when + // present so handlers can widen visibility for owners/admins; anonymous + // callers see approved resources only. + read := v1.Group("/") + read.Use(middleware.OptionalAuth(cfg.JWTSecret)) { - // User routes - protected.GET("/user/profile", handleGetProfile) - protected.PUT("/user/profile", handleUpdateProfile) - protected.POST("/users", handleCreateUser) + read.GET("/projects", handleGetProjects) + read.GET("/projects/:id", handleGetProject) + read.GET("/projects/:id/services", handleGetServices) + read.GET("/projects/:id/preview-environments", handleGetPreviewEnvironments) + read.GET("/projects/:id/security/history", securityHandler.GetProjectSecurityHistory) + read.GET("/projects/:id/vulnerabilities", securityHandler.GetVulnerabilities) + read.GET("/projects/:id/security/metrics", securityHandler.GetSecurityMetrics) - // Platform settings (admin-gated inside handlers) - protected.GET("/settings", handleGetSettings) - protected.PUT("/settings", handleUpdateSettings) + read.GET("/services/:id", handleGetService) + read.GET("/services/:id/metrics", handleGetServiceMetrics) + read.GET("/services/:id/runtime", handleGetServiceRuntime) + read.GET("/services/:id/deployments", handleGetDeployments) + read.GET("/deployments", handleGetRecentDeployments) + read.GET("/deployments/:id", handleGetDeployment) - // Project routes - protected.GET("/projects", handleGetProjects) - protected.POST("/projects", handleCreateProject) + read.GET("/databases", databaseHandler.GetDatabases) + read.GET("/databases/:id", databaseHandler.GetDatabase) - // Service routes (nested under projects) - protected.GET("/projects/:id/services", handleGetServices) - protected.POST("/projects/:id/services", handleCreateService) + read.GET("/templates", handleGetTemplates) + read.GET("/templates/:id", handleGetTemplate) - // Generic project routes - protected.GET("/projects/:id", handleGetProject) - protected.PUT("/projects/:id", handleUpdateProject) - protected.DELETE("/projects/:id", handleDeleteProject) + read.GET("/cron-jobs", handleGetCronJobs) + read.GET("/cron-jobs/:id", handleGetCronJob) + read.GET("/cron-jobs/:id/executions", handleGetCronExecutions) - // Service routes - protected.GET("/services/:id", handleGetService) - protected.PUT("/services/:id", handleUpdateService) - protected.DELETE("/services/:id", handleDeleteService) - protected.GET("/services/:id/metrics", handleGetServiceMetrics) + read.GET("/security/scans/:id", securityHandler.GetSecurityScan) + read.GET("/security/compliance/reports/:id", securityHandler.GetComplianceReport) + read.GET("/security/compliance/frameworks", securityHandler.GetComplianceFrameworks) - // Runtime lifecycle (Railway-style) - protected.GET("/services/:id/runtime", handleGetServiceRuntime) - protected.POST("/services/:id/start", handleServiceStart) - protected.POST("/services/:id/stop", handleServiceStop) - protected.POST("/services/:id/restart", handleServiceRestart) - protected.POST("/services/:id/redeploy", handleServiceRedeploy) + read.GET("/preview-environments/:id", handleGetPreviewEnvironment) - // Deployment routes - protected.GET("/services/:id/deployments", handleGetDeployments) - protected.POST("/services/:id/deployments", handleCreateDeployment) - protected.GET("/deployments", handleGetRecentDeployments) - protected.GET("/deployments/:id", handleGetDeployment) - protected.POST("/deployments/:id/rollback", handleRollbackDeployment) + read.GET("/system/upgrade/status", handleGetUpgradeStatus) + read.GET("/system/host", handleGetHostMonitoring) - // Environment variables routes - protected.GET("/services/:id/variables", handleGetVariables) - protected.PUT("/services/:id/variables", handleUpdateVariables) + // HA + scaling read endpoints (status, policies, health, alerts) + haAPIManager.RegisterReadRoutes(read) + scalingHandler.RegisterReadRoutes(read) + } - // Logs routes - protected.GET("/services/:id/logs", handleGetLogs) - protected.GET("/deployments/:id/logs", handleGetDeploymentLogs) + // Authenticated routes — a valid session is required and handlers + // enforce ownership; platform admins additionally pass every check. + authed := v1.Group("/") + authed.Use(middleware.Auth(cfg.JWTSecret)) + { + authed.GET("/user/profile", handleGetProfile) + authed.PUT("/user/profile", handleUpdateProfile) - // One-off exec console (docker exec, 30s ceiling) - protected.POST("/services/:id/exec", handleExecInService) - - // Git integration routes - protected.GET("/git/github-app/install-url", handleGetGitHubAppInstallURL) - protected.POST("/git/github-app/connect", handleConnectGitHubApp) - protected.GET("/git/providers", handleGetGitProviders) - protected.POST("/git/providers", handleCreateGitProvider) - protected.DELETE("/git/providers/:providerId", handleDeleteGitProvider) - protected.GET("/git/providers/:providerId/repositories", handleGetGitRepositories) - protected.GET("/git/providers/:providerId/repositories/:owner/:repo/branches", handleGetGitRepositoryBranches) - protected.POST("/git/repositories/connect", handleConnectGitRepository) - protected.GET("/git/repositories", handleGetConnectedRepositories) - protected.POST("/git/webhooks", handleCreateWebhook) - - // Build routes - protected.POST("/builds", buildHandler.StartBuild) - protected.GET("/builds", buildHandler.ListBuilds) - protected.GET("/builds/:id", buildHandler.GetBuildStatus) - protected.POST("/builds/:id/cancel", buildHandler.CancelBuild) - protected.GET("/builds/:id/logs", buildHandler.GetBuildLogs) - protected.POST("/builds/plan", buildHandler.GetBuildPlan) - protected.GET("/builds/detect", buildHandler.DetectBuildType) - - // System routes - protected.GET("/system/upgrade/status", handleGetUpgradeStatus) - protected.POST("/system/upgrade/pull", handlePullUpgradeImage) - protected.GET("/system/host", handleGetHostMonitoring) - - // Scaling routes - scalingHandler.RegisterRoutes(protected) - haAPIManager.RegisterRoutes(protected) - - // Database routes - protected.GET("/databases", databaseHandler.GetDatabases) - protected.POST("/databases", databaseHandler.CreateDatabase) - protected.GET("/databases/:id", databaseHandler.GetDatabase) - protected.PUT("/databases/:id", databaseHandler.UpdateDatabase) - protected.DELETE("/databases/:id", databaseHandler.DeleteDatabase) - protected.POST("/databases/:id/action", databaseHandler.PerformDatabaseAction) - protected.POST("/databases/:id/backup", databaseHandler.CreateBackup) - protected.POST("/databases/:id/restore", databaseHandler.RestoreBackup) - protected.GET("/databases/:id/backups/:bid/download", databaseHandler.DownloadBackup) - - // Notification routes - protected.GET("/notifications", handleListNotifications) - protected.POST("/notifications/:id/read", handleMarkNotificationRead) - protected.POST("/notifications/read-all", handleMarkAllNotificationsRead) - - // Node Agent routes - agentHandler.SetupRoutes(protected) - - // Preview Environments routes - protected.GET("/projects/:id/preview-environments", handleGetPreviewEnvironments) - protected.POST("/projects/:id/preview-environments", handleCreatePreviewEnvironment) - protected.GET("/preview-environments/:id", handleGetPreviewEnvironment) - protected.PUT("/preview-environments/:id", handleUpdatePreviewEnvironment) - protected.DELETE("/preview-environments/:id", handleDeletePreviewEnvironment) - protected.POST("/preview-environments/:id/promote", handlePromotePreviewEnvironment) - protected.POST("/preview-environments/cleanup-expired", handleCleanupExpiredPreviewEnvironments) - - // Security routes - protected.POST("/security/scans", securityHandler.StartSecurityScan) - protected.GET("/security/scans/:id", securityHandler.GetSecurityScan) - protected.GET("/projects/:id/security/history", securityHandler.GetProjectSecurityHistory) - protected.GET("/projects/:id/vulnerabilities", securityHandler.GetVulnerabilities) - protected.PUT("/vulnerabilities/:id", securityHandler.UpdateVulnerability) - protected.POST("/security/compliance/assess", securityHandler.StartComplianceAssessment) - protected.GET("/security/compliance/reports/:id", securityHandler.GetComplianceReport) - protected.GET("/security/compliance/frameworks", securityHandler.GetComplianceFrameworks) - protected.POST("/security/compliance/gdpr/init", securityHandler.InitializeGDPRFramework) - protected.GET("/projects/:id/security/metrics", securityHandler.GetSecurityMetrics) - protected.GET("/projects/:id/security/audit-logs", securityHandler.GetAuditLogs) + authed.GET("/notifications", handleListNotifications) + authed.POST("/notifications/:id/read", handleMarkNotificationRead) + authed.POST("/notifications/read-all", handleMarkAllNotificationsRead) // WebSocket endpoint - protected.GET("/ws", handleWebSocket) + authed.GET("/ws", handleWebSocket) - // Templates routes - protected.GET("/templates", handleGetTemplates) - protected.GET("/templates/:id", handleGetTemplate) - protected.POST("/templates/:id/deploy", handleCreateFromTemplate) + // Owned-resource mutations — handlers scope to the caller and + // admit admins. New projects land unapproved until an admin + // publishes them. + authed.POST("/projects", handleCreateProject) + authed.POST("/projects/:id/services", handleCreateService) + authed.PUT("/projects/:id", handleUpdateProject) + authed.DELETE("/projects/:id", handleDeleteProject) - // Cron Jobs routes - protected.GET("/cron-jobs", handleGetCronJobs) - protected.POST("/cron-jobs", handleCreateCronJob) - protected.GET("/cron-jobs/:id", handleGetCronJob) - protected.PUT("/cron-jobs/:id", handleUpdateCronJob) - protected.DELETE("/cron-jobs/:id", handleDeleteCronJob) - protected.GET("/cron-jobs/:id/executions", handleGetCronExecutions) - protected.POST("/cron-jobs/:id/trigger", handleTriggerCronJob) + authed.PUT("/services/:id", handleUpdateService) + authed.DELETE("/services/:id", handleDeleteService) + authed.POST("/services/:id/start", handleServiceStart) + authed.POST("/services/:id/stop", handleServiceStop) + authed.POST("/services/:id/restart", handleServiceRestart) + authed.POST("/services/:id/redeploy", handleServiceRedeploy) + authed.POST("/services/:id/deployments", handleCreateDeployment) + authed.POST("/deployments/:id/rollback", handleRollbackDeployment) - // Audit Logs routes - protected.GET("/audit-logs", handleGetAuditLogs) - protected.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs) + // Environment variables — secrets, owner/admin only. + authed.GET("/services/:id/variables", handleGetVariables) + authed.PUT("/services/:id/variables", handleUpdateVariables) + + // Runtime logs — container stdout routinely echoes secrets, + // so these stay behind authentication like build logs. + authed.GET("/services/:id/logs", handleGetLogs) + authed.GET("/deployments/:id/logs", handleGetDeploymentLogs) + + // One-off exec console (docker exec, 30s ceiling) + authed.POST("/services/:id/exec", handleExecInService) + + // Git integration — providers carry per-user credentials. + authed.GET("/git/github-app/install-url", handleGetGitHubAppInstallURL) + authed.POST("/git/github-app/connect", handleConnectGitHubApp) + authed.GET("/git/providers", handleGetGitProviders) + authed.POST("/git/providers", handleCreateGitProvider) + authed.DELETE("/git/providers/:providerId", handleDeleteGitProvider) + authed.GET("/git/providers/:providerId/repositories", handleGetGitRepositories) + authed.GET("/git/providers/:providerId/repositories/:owner/:repo/branches", handleGetGitRepositoryBranches) + authed.POST("/git/repositories/connect", handleConnectGitRepository) + authed.GET("/git/repositories", handleGetConnectedRepositories) + authed.POST("/git/webhooks", handleCreateWebhook) + + // Builds have no per-user scoping and logs may carry secrets — + // keep every build endpoint behind authentication. + authed.POST("/builds", buildHandler.StartBuild) + authed.GET("/builds", buildHandler.ListBuilds) + authed.GET("/builds/:id", buildHandler.GetBuildStatus) + authed.GET("/builds/:id/logs", buildHandler.GetBuildLogs) + authed.GET("/builds/detect", buildHandler.DetectBuildType) + authed.POST("/builds/:id/cancel", buildHandler.CancelBuild) + authed.POST("/builds/plan", buildHandler.GetBuildPlan) + + authed.POST("/databases", databaseHandler.CreateDatabase) + authed.PUT("/databases/:id", databaseHandler.UpdateDatabase) + authed.DELETE("/databases/:id", databaseHandler.DeleteDatabase) + authed.POST("/databases/:id/action", databaseHandler.PerformDatabaseAction) + authed.POST("/databases/:id/backup", databaseHandler.CreateBackup) + authed.POST("/databases/:id/restore", databaseHandler.RestoreBackup) + authed.GET("/databases/:id/backups/:bid/download", databaseHandler.DownloadBackup) + + authed.POST("/projects/:id/preview-environments", handleCreatePreviewEnvironment) + authed.PUT("/preview-environments/:id", handleUpdatePreviewEnvironment) + authed.DELETE("/preview-environments/:id", handleDeletePreviewEnvironment) + authed.POST("/preview-environments/:id/promote", handlePromotePreviewEnvironment) + + authed.POST("/security/scans", securityHandler.StartSecurityScan) + authed.PUT("/vulnerabilities/:id", securityHandler.UpdateVulnerability) + authed.POST("/security/compliance/assess", securityHandler.StartComplianceAssessment) + authed.POST("/security/compliance/gdpr/init", securityHandler.InitializeGDPRFramework) + authed.GET("/projects/:id/security/audit-logs", securityHandler.GetAuditLogs) + + // User templates — ownership enforced inside the handlers. + authed.POST("/templates", handleCreateTemplate) + authed.PUT("/templates/:id", handleUpdateTemplate) + authed.DELETE("/templates/:id", handleDeleteTemplate) + authed.POST("/templates/:id/deploy", handleCreateFromTemplate) + + authed.POST("/cron-jobs", handleCreateCronJob) + authed.PUT("/cron-jobs/:id", handleUpdateCronJob) + authed.DELETE("/cron-jobs/:id", handleDeleteCronJob) + authed.POST("/cron-jobs/:id/trigger", handleTriggerCronJob) + } + + // Admin routes — platform-wide controls and cross-user management. + admin := v1.Group("/") + admin.Use(middleware.Auth(cfg.JWTSecret)) + admin.Use(middleware.RequireAdmin()) + { + admin.GET("/admin/overview", handleAdminOverview) + admin.GET("/admin/users", handleAdminListUsers) + admin.PATCH("/admin/users/:id", handleAdminSetUserAdmin) + admin.PATCH("/admin/projects/:id", handleAdminSetProjectApproval) + + admin.POST("/users", handleCreateUser) + + admin.GET("/settings", handleGetSettings) + admin.PUT("/settings", handleUpdateSettings) + + admin.POST("/system/upgrade/pull", handlePullUpgradeImage) + admin.POST("/preview-environments/cleanup-expired", handleCleanupExpiredPreviewEnvironments) + + // HA + scaling + agent mutations — platform-level controls. + haAPIManager.RegisterAdminRoutes(admin) + scalingHandler.RegisterAdminRoutes(admin) + agentHandler.SetupAdminRoutes(admin) + + admin.GET("/audit-logs", handleGetAuditLogs) + admin.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs) // API Gateway routes (merged APwhy) - namespaced to avoid // colliding with Containr's own /services paths. - gateway := protected.Group("/gateway") + gateway := admin.Group("/gateway") { gateway.GET("/services", handleAPwhyServicesList) gateway.POST("/services", handleAPwhyServicesCreate) gateway.PATCH("/services/:id", handleAPwhyServicesPatch) - gateway.GET("/services/:id/validate", handleAPwhyServiceValidate) + gateway.GET("/services/:id", handleAPwhyServiceValidate) gateway.GET("/keys", handleAPwhyKeysList) gateway.POST("/keys", handleAPwhyKeysCreate) diff --git a/app/backend/internal/api/runtime.go b/app/backend/internal/api/runtime.go index bdb8300..48eaf2a 100644 --- a/app/backend/internal/api/runtime.go +++ b/app/backend/internal/api/runtime.go @@ -225,13 +225,56 @@ func loadOwnedService(c *gin.Context, db *database.DB) (Service, bool) { return service, true } +// loadReadableService loads a service when the caller may read its project: +// approved projects are public, unapproved ones need owner/member/admin. +func loadReadableService(c *gin.Context, db *database.DB) (Service, bool) { + serviceID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"}) + return Service{}, false + } + + var service Service + var projectID uuid.UUID + err = db.QueryRow( + `SELECT s.id, s.project_id, s.name, + COALESCE(s.type, s.service_type, ''), COALESCE(s.status, ''), + COALESCE(s.image, s.image_name, ''), COALESCE(s.command, s.start_command, ''), + COALESCE(s.environment, ''), COALESCE(s.git_repo, s.source_url, ''), + COALESCE(s.git_branch, ''), COALESCE(s.build_path, ''), + COALESCE(s.cpu, ''), COALESCE(s.memory, ''), + COALESCE(s.replicas, 1), COALESCE(s.port, 0), + COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''), + COALESCE(s.restart_policy, 'unless-stopped'), + s.created_at, s.updated_at, s.project_id + FROM services s + WHERE s.id = $1`, + serviceID, + ).Scan( + &service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status, + &service.Image, &service.Command, &service.Environment, &service.GitRepo, + &service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory, + &service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath, + &service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &projectID, + ) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"}) + return Service{}, false + } + if _, allowed := projectReadAccess(c, db, projectID); !allowed { + c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"}) + return Service{}, false + } + return service, true +} + // handleServiceRuntime returns live container state for a service. func handleGetServiceRuntime(c *gin.Context) { engine, db, ok := getRuntimeEngine(c) if !ok { return } - service, ok := loadOwnedService(c, db) + service, ok := loadReadableService(c, db) if !ok { return } diff --git a/app/backend/internal/api/scaling.go b/app/backend/internal/api/scaling.go index 1968722..528f0ba 100644 --- a/app/backend/internal/api/scaling.go +++ b/app/backend/internal/api/scaling.go @@ -24,31 +24,39 @@ func NewScalingHandler(autoScaler *scaling.AutoScaler) *ScalingHandler { } } -// RegisterRoutes registers scaling routes -func (h *ScalingHandler) RegisterRoutes(router *gin.RouterGroup) { +// RegisterReadRoutes registers public scaling read endpoints. +func (h *ScalingHandler) RegisterReadRoutes(router *gin.RouterGroup) { scaling := router.Group("/scaling") { scaling.GET("/policies", h.GetScalingPolicies) - scaling.POST("/policies", h.SetScalingPolicy) scaling.GET("/policies/:serviceId", h.GetScalingPolicy) - scaling.PUT("/policies/:serviceId", h.UpdateScalingPolicy) - scaling.DELETE("/policies/:serviceId", h.DeleteScalingPolicy) scaling.GET("/services", h.GetServiceStates) scaling.GET("/services/:serviceId", h.GetServiceState) scaling.GET("/services/:serviceId/history", h.GetScalingHistory) - scaling.POST("/services/:serviceId/scale", h.ManualScale) - scaling.GET("/status", h.GetScalingStatus) - scaling.POST("/enable", h.EnableAutoScaler) - scaling.POST("/disable", h.DisableAutoScaler) scaling.GET("/metrics", h.GetScalingMetrics) scaling.GET("/events", h.GetScalingEvents) } } +// RegisterAdminRoutes registers scaling mutation endpoints. +func (h *ScalingHandler) RegisterAdminRoutes(router *gin.RouterGroup) { + scaling := router.Group("/scaling") + { + scaling.POST("/policies", h.SetScalingPolicy) + scaling.PUT("/policies/:serviceId", h.UpdateScalingPolicy) + scaling.DELETE("/policies/:serviceId", h.DeleteScalingPolicy) + + scaling.POST("/services/:serviceId/scale", h.ManualScale) + + scaling.POST("/enable", h.EnableAutoScaler) + scaling.POST("/disable", h.DisableAutoScaler) + } +} + // GetScalingPolicies returns all scaling policies func (h *ScalingHandler) GetScalingPolicies(c *gin.Context) { states := h.autoScaler.GetAllServiceStates() diff --git a/app/backend/internal/api/scaling_test.go b/app/backend/internal/api/scaling_test.go index ff9721f..ba592d7 100644 --- a/app/backend/internal/api/scaling_test.go +++ b/app/backend/internal/api/scaling_test.go @@ -49,7 +49,9 @@ func setupScalingTestRouter(t *testing.T) *gin.Engine { gin.SetMode(gin.TestMode) router := gin.New() handler := NewScalingHandler(autoScaler) - handler.RegisterRoutes(router.Group("/api/v1")) + group := router.Group("/api/v1") + handler.RegisterReadRoutes(group) + handler.RegisterAdminRoutes(group) return router } diff --git a/app/backend/internal/api/security.go b/app/backend/internal/api/security.go index 0a61e26..05aa255 100644 --- a/app/backend/internal/api/security.go +++ b/app/backend/internal/api/security.go @@ -589,16 +589,7 @@ func (sh *SecurityHandler) GetAuditLogs(c *gin.Context) { } func (sh *SecurityHandler) requireProjectAccess(c *gin.Context, projectID string) (string, bool) { - userIDValue, exists := c.Get("user_id") - if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) - return "", false - } - userID, ok := userIDValue.(string) - if !ok || userID == "" { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"}) - return "", false - } + userID := optionalUserID(c) if _, err := uuid.Parse(projectID); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"}) @@ -611,12 +602,14 @@ func (sh *SecurityHandler) requireProjectAccess(c *gin.Context, projectID string SELECT 1 FROM projects p WHERE p.id = $1 - AND (p.owner_id = $2 OR EXISTS ( + AND (p.is_approved + OR $3::bool + OR p.owner_id = $2 OR EXISTS ( SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2 )) )`, - projectID, userID, + projectID, optionalUserUUID(c), contextIsAdmin(c), ).Scan(&hasAccess) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify project access"}) diff --git a/app/backend/internal/api/services.go b/app/backend/internal/api/services.go index a5932ec..b4e2c47 100644 --- a/app/backend/internal/api/services.go +++ b/app/backend/internal/api/services.go @@ -91,28 +91,14 @@ func handleGetServices(c *gin.Context) { return } - // Check if project exists and user has access - var project Project - err = db.(*database.DB).QueryRow( - "SELECT id, name, owner_id FROM projects WHERE id = $1", - projectID, - ).Scan(&project.ID, &project.Name, &project.OwnerID) - - if err != nil { + // Approved projects are public; unapproved ones need owner/member/admin. + projectExists, allowed := projectReadAccess(c, db.(*database.DB), projectID) + if !projectExists { c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) return } - - // Get user ID from JWT token (set by auth middleware) - userID, exists := c.Get("user_id") - if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) - return - } - - // Check if user owns the project - if project.OwnerID != userID.(string) { - c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) + if !allowed { + c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) return } @@ -217,7 +203,7 @@ func handleCreateService(c *gin.Context) { } // Check if user owns the project - if project.OwnerID != userID.(string) { + if project.OwnerID != userID.(string) && !contextIsAdmin(c) { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } @@ -344,14 +330,9 @@ func handleGetService(c *gin.Context) { return } - // Get user ID from JWT token - userID, exists := c.Get("user_id") - if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) - return - } - - // Get service with project ownership check + // Get service — public when its project is approved, otherwise + // owner/member/admin only. + userID := optionalUserUUID(c) var service Service err = db.(*database.DB).QueryRow( `SELECT s.id, s.project_id, s.name, @@ -371,8 +352,9 @@ func handleGetService(c *gin.Context) { s.created_at, s.updated_at FROM services s JOIN projects p ON s.project_id = p.id - WHERE s.id = $1 AND p.owner_id = $2`, - serviceID, userID, + WHERE s.id = $1 AND (p.is_approved OR p.owner_id = $2 OR $3::bool + OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2))`, + serviceID, userID, contextIsAdmin(c), ).Scan( &service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status, &service.Image, &service.Command, &service.Environment, &service.GitRepo, @@ -438,8 +420,8 @@ func handleUpdateService(c *gin.Context) { s.created_at, s.updated_at FROM services s JOIN projects p ON s.project_id = p.id - WHERE s.id = $1 AND p.owner_id = $2`, - serviceID, userID, + WHERE s.id = $1 AND (p.owner_id = $2 OR $3::bool)`, + serviceID, userID, contextIsAdmin(c), ).Scan( &existingService.ID, &existingService.ProjectID, &existingService.Name, &existingService.Type, &existingService.Status, &existingService.Image, &existingService.Command, diff --git a/app/backend/internal/api/templates.go b/app/backend/internal/api/templates.go index ea91ddb..8f05600 100644 --- a/app/backend/internal/api/templates.go +++ b/app/backend/internal/api/templates.go @@ -3,16 +3,19 @@ package api import ( "containr/internal/database" "containr/internal/database/sqlcdb" + "context" "database/sql" "encoding/json" "errors" "fmt" + "log" "net/http" "strings" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" + "github.com/sqlc-dev/pqtype" ) type ServiceTemplate struct { @@ -24,6 +27,7 @@ type ServiceTemplate struct { Config string `json:"config" db:"config"` Variables string `json:"variables" db:"variables"` IsOfficial bool `json:"is_official" db:"is_official"` + OwnerID string `json:"owner_id,omitempty" db:"owner_id"` CreatedAt time.Time `json:"created_at" db:"created_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"` } @@ -67,12 +71,18 @@ func handleGetTemplates(c *gin.Context) { ctx := c.Request.Context() queries := sqlcdb.New(db.DB) + userID := optionalUserUUID(c) + owner := uuid.NullUUID{UUID: userID, Valid: userID != uuid.Nil} + var templateRows []sqlcdb.ServiceTemplate var err error if category != "" { - templateRows, err = queries.ListServiceTemplatesByCategory(ctx, category) + templateRows, err = queries.ListServiceTemplatesByCategoryForUser(ctx, sqlcdb.ListServiceTemplatesByCategoryForUserParams{ + Category: category, + OwnerID: owner, + }) } else { - templateRows, err = queries.ListServiceTemplates(ctx) + templateRows, err = queries.ListServiceTemplatesForUser(ctx, owner) } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"}) @@ -103,7 +113,7 @@ func handleGetTemplate(c *gin.Context) { } t := mapSQLCTemplate(row) - if t.ID == "" { + if t.ID == "" || !templateVisibleTo(c, t) { c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"}) return } @@ -169,7 +179,7 @@ func handleCreateFromTemplate(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch project"}) return } - if ownerID.String() != userID { + if ownerID.String() != userID && !contextIsAdmin(c) { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } @@ -185,8 +195,8 @@ func handleCreateFromTemplate(c *gin.Context) { } template := mapSQLCTemplate(templateRow) - if template.ID == "" { - c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) + if template.ID == "" || !templateVisibleTo(c, template) { + c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"}) return } @@ -363,6 +373,243 @@ func handleCreateFromTemplate(c *gin.Context) { }) } +// templateWriteRequest is the admin payload for creating/updating a user +// template. config must decode into TemplateConfig; variables into +// []TemplateVariable. +type templateWriteRequest struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` + Category string `json:"category"` + Logo string `json:"logo"` + Config json.RawMessage `json:"config" binding:"required"` + Variables json.RawMessage `json:"variables"` +} + +var templateCategories = map[string]bool{ + "web": true, "frontend": true, "api": true, "database": true, + "worker": true, "cron": true, "app": true, "custom": true, +} + +func normalizeTemplateCategory(category string) string { + normalized := strings.ToLower(strings.TrimSpace(category)) + if templateCategories[normalized] { + return normalized + } + return "custom" +} + +func validateTemplateWrite(c *gin.Context, req *templateWriteRequest) bool { + req.Name = strings.TrimSpace(req.Name) + if len(req.Name) < 2 || len(req.Name) > 120 { + c.JSON(http.StatusBadRequest, gin.H{"error": "name must be 2-120 characters"}) + return false + } + req.Category = normalizeTemplateCategory(req.Category) + req.Logo = strings.TrimSpace(req.Logo) + if len(req.Logo) > 500 || (req.Logo != "" && !strings.HasPrefix(req.Logo, "https://") && !strings.HasPrefix(req.Logo, "http://")) { + c.JSON(http.StatusBadRequest, gin.H{"error": "logo must be an http(s) URL up to 500 chars"}) + return false + } + if len(req.Config) == 0 || len(req.Config) > 64*1024 { + c.JSON(http.StatusBadRequest, gin.H{"error": "config is required and must be under 64KB"}) + return false + } + var config TemplateConfig + if err := json.Unmarshal(req.Config, &config); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "config must be a valid template config object"}) + return false + } + if _, err := normalizeTemplateServiceType(config.Type); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return false + } + if len(req.Variables) > 0 { + if len(req.Variables) > 64*1024 { + c.JSON(http.StatusBadRequest, gin.H{"error": "variables must be under 64KB"}) + return false + } + var variables []TemplateVariable + if err := json.Unmarshal(req.Variables, &variables); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "variables must be an array"}) + return false + } + for _, v := range variables { + if strings.TrimSpace(v.Key) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "every variable needs a key"}) + return false + } + } + } + return true +} + +func templateVariablesParam(raw json.RawMessage) pqtype.NullRawMessage { + if len(raw) == 0 { + return pqtype.NullRawMessage{} + } + return pqtype.NullRawMessage{RawMessage: raw, Valid: true} +} + +func handleCreateTemplate(c *gin.Context) { + userID, ok := requireAuthenticatedUserID(c) + if !ok { + return + } + db := c.MustGet("db").(*database.DB) + queries := sqlcdb.New(db.DB) + + var req templateWriteRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if !validateTemplateWrite(c, &req) { + return + } + + ownerUUID, err := uuid.Parse(userID) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"}) + return + } + + id := "usr-" + uuid.NewString() + if err := queries.CreateUserTemplate(c.Request.Context(), sqlcdb.CreateUserTemplateParams{ + ID: id, + Name: req.Name, + Description: nullableText(strings.TrimSpace(req.Description)), + Category: req.Category, + Logo: nullableText(req.Logo), + Config: req.Config, + Variables: templateVariablesParam(req.Variables), + OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true}, + }); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create template"}) + return + } + + LogAudit(userID, "template", id, "create", map[string]interface{}{"name": req.Name}) + + row, err := queries.GetServiceTemplateByID(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusCreated, gin.H{"template": gin.H{"id": id, "name": req.Name}}) + return + } + c.JSON(http.StatusCreated, gin.H{"template": mapSQLCTemplate(row)}) +} + +func handleUpdateTemplate(c *gin.Context) { + userID, ok := requireAuthenticatedUserID(c) + if !ok { + return + } + db := c.MustGet("db").(*database.DB) + queries := sqlcdb.New(db.DB) + templateID := c.Param("id") + + var req templateWriteRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if !validateTemplateWrite(c, &req) { + return + } + + ownerUUID, err := uuid.Parse(userID) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"}) + return + } + + rows, err := queries.UpdateUserTemplate(c.Request.Context(), sqlcdb.UpdateUserTemplateParams{ + Name: req.Name, + Description: nullableText(strings.TrimSpace(req.Description)), + Category: req.Category, + Logo: nullableText(req.Logo), + Config: req.Config, + Variables: templateVariablesParam(req.Variables), + ID: templateID, + OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true}, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update template"}) + return + } + if rows == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Template not found or not editable"}) + return + } + + LogAudit(userID, "template", templateID, "update", map[string]interface{}{"name": req.Name}) + + row, err := queries.GetServiceTemplateByID(c.Request.Context(), templateID) + if err != nil { + c.JSON(http.StatusOK, gin.H{"message": "Template updated"}) + return + } + c.JSON(http.StatusOK, gin.H{"template": mapSQLCTemplate(row)}) +} + +func handleDeleteTemplate(c *gin.Context) { + userID, ok := requireAuthenticatedUserID(c) + if !ok { + return + } + db := c.MustGet("db").(*database.DB) + queries := sqlcdb.New(db.DB) + templateID := c.Param("id") + + ownerUUID, err := uuid.Parse(userID) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"}) + return + } + + rows, err := queries.DeleteUserTemplate(c.Request.Context(), sqlcdb.DeleteUserTemplateParams{ + ID: templateID, + OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true}, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete template"}) + return + } + if rows == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Template not found or not deletable"}) + return + } + + LogAudit(userID, "template", templateID, "delete", nil) + c.JSON(http.StatusOK, gin.H{"message": "Template deleted"}) +} + +// SeedOfficialTemplates upserts the built-in catalog on startup so new +// templates ship with releases without needing a migration per entry. +func SeedOfficialTemplates(ctx context.Context, db *database.DB) { + if db == nil || db.DB == nil { + return + } + queries := sqlcdb.New(db.DB) + for _, t := range SeedTemplates() { + variables := pqtype.NullRawMessage{} + if strings.TrimSpace(t.Variables) != "" { + variables = pqtype.NullRawMessage{RawMessage: json.RawMessage(t.Variables), Valid: true} + } + if err := queries.UpsertServiceTemplate(ctx, sqlcdb.UpsertServiceTemplateParams{ + ID: t.ID, + Name: t.Name, + Description: nullableText(t.Description), + Category: t.Category, + Logo: nullableText(t.Logo), + Config: json.RawMessage(t.Config), + Variables: variables, + IsOfficial: sql.NullBool{Bool: true, Valid: true}, + }); err != nil { + log.Printf("Failed to seed template %s: %v", t.ID, err) + } + } +} + func SeedTemplates() []ServiceTemplate { templates := []ServiceTemplate{ { @@ -505,6 +752,167 @@ func SeedTemplates() []ServiceTemplate { Variables: `[{"key":"IMAGE","label":"Docker Image","default":"","required":true,"secret":false},{"key":"TAG","label":"Image Tag","default":"latest","required":false,"secret":false}]`, IsOfficial: true, }, + // Self-hosted applications — runtime doubles as the image reference. + { + ID: "tpl-nextcloud", + Name: "Nextcloud", + Description: "Self-hosted file sync, share and collaboration platform", + Category: "app", + Logo: "https://cdn.simpleicons.org/nextcloud", + Config: `{"type":"web","runtime":"nextcloud:30-apache","port":80,"health_check":"/status.php"}`, + Variables: `[{"key":"NEXTCLOUD_ADMIN_USER","label":"Admin User","default":"admin","required":false,"secret":false},{"key":"NEXTCLOUD_ADMIN_PASSWORD","label":"Admin Password","default":"","required":true,"secret":true},{"key":"NEXTCLOUD_TRUSTED_DOMAINS","label":"Trusted Domains","default":"","required":false,"secret":false}]`, + IsOfficial: true, + }, + { + ID: "tpl-gitea", + Name: "Gitea", + Description: "Lightweight self-hosted Git service", + Category: "app", + Logo: "https://cdn.simpleicons.org/gitea", + Config: `{"type":"web","runtime":"gitea/gitea:1","port":3000,"health_check":"/api/healthz"}`, + Variables: `[{"key":"GITEA__server__DOMAIN","label":"Server Domain","default":"localhost","required":false,"secret":false},{"key":"GITEA__server__ROOT_URL","label":"Root URL","default":"","required":false,"secret":false}]`, + IsOfficial: true, + }, + { + ID: "tpl-ghost", + Name: "Ghost", + Description: "Independent publishing platform for blogs and newsletters", + Category: "app", + Logo: "https://cdn.simpleicons.org/ghost", + Config: `{"type":"web","runtime":"ghost:5-alpine","port":2368,"health_check":"/ghost/api/admin/site/"}`, + Variables: `[{"key":"url","label":"Public URL","default":"","required":true,"secret":false},{"key":"database__client","label":"Database Client","default":"sqlite3","required":false,"secret":false}]`, + IsOfficial: true, + }, + { + ID: "tpl-uptime-kuma", + Name: "Uptime Kuma", + Description: "Self-hosted monitoring tool for services and websites", + Category: "app", + Logo: "https://cdn.simpleicons.org/uptimerobot", + Config: `{"type":"web","runtime":"louislam/uptime-kuma:1","port":3001,"health_check":"/"}`, + Variables: `[]`, + IsOfficial: true, + }, + { + ID: "tpl-vaultwarden", + Name: "Vaultwarden", + Description: "Lightweight Bitwarden-compatible password manager", + Category: "app", + Logo: "https://cdn.simpleicons.org/bitwarden", + Config: `{"type":"web","runtime":"vaultwarden/server:latest","port":80,"health_check":"/alive"}`, + Variables: `[{"key":"ADMIN_TOKEN","label":"Admin Token","default":"","required":false,"secret":true},{"key":"SIGNUPS_ALLOWED","label":"Allow Signups","default":"false","required":false,"secret":false},{"key":"DOMAIN","label":"Domain URL","default":"","required":false,"secret":false}]`, + IsOfficial: true, + }, + { + ID: "tpl-jellyfin", + Name: "Jellyfin", + Description: "Free software media system for movies, shows and music", + Category: "app", + Logo: "https://cdn.simpleicons.org/jellyfin", + Config: `{"type":"web","runtime":"jellyfin/jellyfin:latest","port":8096,"health_check":"/health"}`, + Variables: `[]`, + IsOfficial: true, + }, + { + ID: "tpl-n8n", + Name: "n8n", + Description: "Workflow automation tool with hundreds of integrations", + Category: "app", + Logo: "https://cdn.simpleicons.org/n8n", + Config: `{"type":"web","runtime":"n8nio/n8n:latest","port":5678,"health_check":"/healthz"}`, + Variables: `[{"key":"N8N_HOST","label":"Hostname","default":"","required":false,"secret":false},{"key":"N8N_ENCRYPTION_KEY","label":"Encryption Key","default":"","required":true,"secret":true},{"key":"WEBHOOK_URL","label":"Webhook URL","default":"","required":false,"secret":false}]`, + IsOfficial: true, + }, + { + ID: "tpl-grafana", + Name: "Grafana", + Description: "Observability dashboards for metrics, logs and traces", + Category: "app", + Logo: "https://cdn.simpleicons.org/grafana", + Config: `{"type":"web","runtime":"grafana/grafana:latest","port":3000,"health_check":"/api/health"}`, + Variables: `[{"key":"GF_SECURITY_ADMIN_PASSWORD","label":"Admin Password","default":"","required":true,"secret":true}]`, + IsOfficial: true, + }, + { + ID: "tpl-immich", + Name: "Immich", + Description: "Self-hosted photo and video backup", + Category: "app", + Logo: "https://cdn.simpleicons.org/immich", + Config: `{"type":"web","runtime":"ghcr.io/immich-app/immich-server:release","port":2283,"health_check":"/api/server/ping"}`, + Variables: `[{"key":"DB_PASSWORD","label":"Database Password","default":"","required":true,"secret":true},{"key":"DB_HOSTNAME","label":"Database Host","default":"","required":false,"secret":false}]`, + IsOfficial: true, + }, + { + ID: "tpl-meilisearch", + Name: "Meilisearch", + Description: "Fast, typo-tolerant search engine API", + Category: "app", + Logo: "https://cdn.simpleicons.org/meilisearch", + Config: `{"type":"web","runtime":"getmeili/meilisearch:v1","port":7700,"health_check":"/health"}`, + Variables: `[{"key":"MEILI_MASTER_KEY","label":"Master Key","default":"","required":true,"secret":true},{"key":"MEILI_ENV","label":"Environment","default":"production","required":false,"secret":false}]`, + IsOfficial: true, + }, + { + ID: "tpl-plausible", + Name: "Plausible Analytics", + Description: "Lightweight, privacy-friendly web analytics", + Category: "app", + Logo: "https://cdn.simpleicons.org/plausibleanalytics", + Config: `{"type":"web","runtime":"ghcr.io/plausible/community-edition:v2","port":8000,"health_check":"/api/health"}`, + Variables: `[{"key":"BASE_URL","label":"Base URL","default":"","required":true,"secret":false},{"key":"SECRET_KEY_BASE","label":"Secret Key Base","default":"","required":true,"secret":true}]`, + IsOfficial: true, + }, + { + ID: "tpl-minio", + Name: "MinIO", + Description: "S3-compatible object storage", + Category: "app", + Logo: "https://cdn.simpleicons.org/minio", + Config: `{"type":"web","runtime":"minio/minio:latest","start_command":"server /data --console-address \":9001\"","port":9001,"health_check":"/minio/health/live"}`, + Variables: `[{"key":"MINIO_ROOT_USER","label":"Root User","default":"minioadmin","required":true,"secret":false},{"key":"MINIO_ROOT_PASSWORD","label":"Root Password","default":"","required":true,"secret":true}]`, + IsOfficial: true, + }, + { + ID: "tpl-appwrite", + Name: "Appwrite", + Description: "Backend-as-a-service for web and mobile apps", + Category: "app", + Logo: "https://cdn.simpleicons.org/appwrite", + Config: `{"type":"web","runtime":"appwrite/appwrite:1.6","port":80,"health_check":"/health"}`, + Variables: `[]`, + IsOfficial: true, + }, + { + ID: "tpl-wordpress", + Name: "WordPress", + Description: "Classic CMS powering a large share of the web", + Category: "app", + Logo: "https://cdn.simpleicons.org/wordpress", + Config: `{"type":"web","runtime":"wordpress:6-apache","port":80,"health_check":"/wp-login.php"}`, + Variables: `[{"key":"WORDPRESS_DB_HOST","label":"Database Host","default":"","required":true,"secret":false},{"key":"WORDPRESS_DB_USER","label":"Database User","default":"wordpress","required":true,"secret":false},{"key":"WORDPRESS_DB_PASSWORD","label":"Database Password","default":"","required":true,"secret":true},{"key":"WORDPRESS_DB_NAME","label":"Database Name","default":"wordpress","required":true,"secret":false}]`, + IsOfficial: true, + }, + { + ID: "tpl-paperless", + Name: "Paperless-ngx", + Description: "Document management system with OCR", + Category: "app", + Logo: "https://cdn.simpleicons.org/paperlessngx", + Config: `{"type":"web","runtime":"ghcr.io/paperless-ngx/paperless-ngx:latest","port":8000,"health_check":"/api/"}`, + Variables: `[{"key":"PAPERLESS_ADMIN_PASSWORD","label":"Admin Password","default":"","required":true,"secret":true},{"key":"PAPERLESS_URL","label":"Public URL","default":"","required":false,"secret":false}]`, + IsOfficial: true, + }, + { + ID: "tpl-ntfy", + Name: "ntfy", + Description: "Push notification service over HTTP", + Category: "app", + Logo: "https://cdn.simpleicons.org/ntfy", + Config: `{"type":"web","runtime":"binwiederhier/ntfy:latest","start_command":"serve","port":80,"health_check":"/v1/health"}`, + Variables: `[]`, + IsOfficial: true, + }, } return templates } @@ -605,12 +1013,30 @@ func defaultTemplateResources(serviceType string) (cpu, memory string) { } } +// templateVisibleTo reports whether the caller may see a template: official +// templates are public, user templates are owner/admin only. +func templateVisibleTo(c *gin.Context, t ServiceTemplate) bool { + if t.IsOfficial { + return true + } + if contextIsAdmin(c) { + return true + } + userID := optionalUserID(c) + return userID != "" && t.OwnerID == userID +} + func mapSQLCTemplate(row sqlcdb.ServiceTemplate) ServiceTemplate { variables := "[]" if row.Variables.Valid && len(row.Variables.RawMessage) > 0 { variables = string(row.Variables.RawMessage) } + ownerID := "" + if row.OwnerID.Valid { + ownerID = row.OwnerID.UUID.String() + } + return ServiceTemplate{ ID: row.ID, Name: row.Name, @@ -620,6 +1046,7 @@ func mapSQLCTemplate(row sqlcdb.ServiceTemplate) ServiceTemplate { Config: string(row.Config), Variables: variables, IsOfficial: row.IsOfficial.Valid && row.IsOfficial.Bool, + OwnerID: ownerID, CreatedAt: templateNullTime(row.CreatedAt), UpdatedAt: templateNullTime(row.UpdatedAt), } diff --git a/app/backend/internal/api/variables.go b/app/backend/internal/api/variables.go index 8a53dd9..709b22a 100644 --- a/app/backend/internal/api/variables.go +++ b/app/backend/internal/api/variables.go @@ -47,26 +47,12 @@ func handleGetVariables(c *gin.Context) { return } - userID, exists := c.Get("user_id") - if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) - return - } - - var ownerCheck string - err = db.(*database.DB).QueryRow( - `SELECT p.owner_id FROM services s - JOIN projects p ON s.project_id = p.id - WHERE s.id = $1`, - serviceID, - ).Scan(&ownerCheck) - - if err != nil { + projectID, found := serviceProjectID(db.(*database.DB), serviceID) + if !found { c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"}) return } - - if ownerCheck != userID.(string) { + if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } @@ -123,26 +109,12 @@ func handleUpdateVariables(c *gin.Context) { return } - userID, exists := c.Get("user_id") - if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) - return - } - - var ownerCheck string - err = db.(*database.DB).QueryRow( - `SELECT p.owner_id FROM services s - JOIN projects p ON s.project_id = p.id - WHERE s.id = $1`, - serviceID, - ).Scan(&ownerCheck) - - if err != nil { + projectID, found := serviceProjectID(db.(*database.DB), serviceID) + if !found { c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"}) return } - - if ownerCheck != userID.(string) { + if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } diff --git a/app/backend/internal/config/config.go b/app/backend/internal/config/config.go index ffe00d3..459f4b1 100644 --- a/app/backend/internal/config/config.go +++ b/app/backend/internal/config/config.go @@ -91,6 +91,12 @@ type Config struct { // Dashboard UI Configuration DashboardUIBasePath string `env:"DASHBOARD_UI_BASE_PATH" default:"/"` + + // Admin bootstrap — when set, the account is provisioned/promoted on + // startup so the owner always has admin credentials that work. + AdminEmail string `env:"ADMIN_EMAIL" default:""` + AdminPassword string `env:"ADMIN_PASSWORD" default:""` + AdminName string `env:"ADMIN_NAME" default:""` } // Environment represents the application environment @@ -181,6 +187,10 @@ func Load() *Config { cfg.DashboardUIBasePath = getenv("DASHBOARD_UI_BASE_PATH", "/") + cfg.AdminEmail = getenv("ADMIN_EMAIL", "") + cfg.AdminPassword = getenv("ADMIN_PASSWORD", "") + cfg.AdminName = getenv("ADMIN_NAME", "") + // Validate configuration if err := cfg.Validate(); err != nil { panic(fmt.Sprintf("Invalid configuration: %v", err)) diff --git a/app/backend/internal/database/postgres.go b/app/backend/internal/database/postgres.go index 4ba7900..7a08d24 100644 --- a/app/backend/internal/database/postgres.go +++ b/app/backend/internal/database/postgres.go @@ -55,5 +55,5 @@ func (db *DB) Health(ctx context.Context) error { // Stats returns connection pool statistics for monitoring func (db *DB) Stats() sql.DBStats { - return db.Stats() + return db.DB.Stats() } diff --git a/app/backend/internal/database/sqlcdb/databases.sql.go b/app/backend/internal/database/sqlcdb/databases.sql.go index 2c969c7..48ac8e6 100644 --- a/app/backend/internal/database/sqlcdb/databases.sql.go +++ b/app/backend/internal/database/sqlcdb/databases.sql.go @@ -149,37 +149,18 @@ func (q *Queries) GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, a return i, err } -const getDatabaseServiceByIDAndUser = `-- name: GetDatabaseServiceByIDAndUser :one -SELECT id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at +const getDatabaseServiceByID = `-- name: GetDatabaseServiceByID :one +SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at FROM database_services -WHERE id = $1 AND user_id = $2 +WHERE id = $1 ` -type GetDatabaseServiceByIDAndUserParams struct { - ID string `json:"id"` - UserID string `json:"user_id"` -} - -type GetDatabaseServiceByIDAndUserRow struct { - ID string `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - Status string `json:"status"` - Version string `json:"version"` - Plan string `json:"plan"` - Region string `json:"region"` - ConnectionUrl sql.NullString `json:"connection_url"` - BackupSchedule sql.NullString `json:"backup_schedule"` - NextBackupAt sql.NullTime `json:"next_backup_at"` - CreatedAt sql.NullTime `json:"created_at"` - UpdatedAt sql.NullTime `json:"updated_at"` -} - -func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (GetDatabaseServiceByIDAndUserRow, error) { - row := q.db.QueryRowContext(ctx, getDatabaseServiceByIDAndUser, arg.ID, arg.UserID) - var i GetDatabaseServiceByIDAndUserRow +func (q *Queries) GetDatabaseServiceByID(ctx context.Context, id string) (DatabaseService, error) { + row := q.db.QueryRowContext(ctx, getDatabaseServiceByID, id) + var i DatabaseService err := row.Scan( &i.ID, + &i.UserID, &i.Name, &i.Type, &i.Status, @@ -195,6 +176,94 @@ func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetData return i, err } +const getDatabaseServiceByIDAndUser = `-- name: GetDatabaseServiceByIDAndUser :one +SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at +FROM database_services +WHERE id = $1 AND user_id = $2 +` + +type GetDatabaseServiceByIDAndUserParams struct { + ID string `json:"id"` + UserID string `json:"user_id"` +} + +func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (DatabaseService, error) { + row := q.db.QueryRowContext(ctx, getDatabaseServiceByIDAndUser, arg.ID, arg.UserID) + var i DatabaseService + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Type, + &i.Status, + &i.Version, + &i.Plan, + &i.Region, + &i.ConnectionUrl, + &i.BackupSchedule, + &i.NextBackupAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getDatabaseServiceOwnerID = `-- name: GetDatabaseServiceOwnerID :one +SELECT user_id +FROM database_services +WHERE id = $1 +` + +func (q *Queries) GetDatabaseServiceOwnerID(ctx context.Context, id string) (string, error) { + row := q.db.QueryRowContext(ctx, getDatabaseServiceOwnerID, id) + var user_id string + err := row.Scan(&user_id) + return user_id, err +} + +const listAllDatabaseServices = `-- name: ListAllDatabaseServices :many +SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at +FROM database_services +ORDER BY created_at DESC +` + +func (q *Queries) ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error) { + rows, err := q.db.QueryContext(ctx, listAllDatabaseServices) + if err != nil { + return nil, err + } + defer rows.Close() + items := []DatabaseService{} + for rows.Next() { + var i DatabaseService + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Type, + &i.Status, + &i.Version, + &i.Plan, + &i.Region, + &i.ConnectionUrl, + &i.BackupSchedule, + &i.NextBackupAt, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listDatabaseBackupsByDatabaseAndUser = `-- name: ListDatabaseBackupsByDatabaseAndUser :many SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.created_at, b.completed_at FROM database_backups b @@ -242,38 +311,24 @@ func (q *Queries) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg } const listDatabaseServicesByUser = `-- name: ListDatabaseServicesByUser :many -SELECT id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at +SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at FROM database_services WHERE user_id = $1 ORDER BY created_at DESC ` -type ListDatabaseServicesByUserRow struct { - ID string `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - Status string `json:"status"` - Version string `json:"version"` - Plan string `json:"plan"` - Region string `json:"region"` - ConnectionUrl sql.NullString `json:"connection_url"` - BackupSchedule sql.NullString `json:"backup_schedule"` - NextBackupAt sql.NullTime `json:"next_backup_at"` - CreatedAt sql.NullTime `json:"created_at"` - UpdatedAt sql.NullTime `json:"updated_at"` -} - -func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string) ([]ListDatabaseServicesByUserRow, error) { +func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error) { rows, err := q.db.QueryContext(ctx, listDatabaseServicesByUser, userID) if err != nil { return nil, err } defer rows.Close() - items := []ListDatabaseServicesByUserRow{} + items := []DatabaseService{} for rows.Next() { - var i ListDatabaseServicesByUserRow + var i DatabaseService if err := rows.Scan( &i.ID, + &i.UserID, &i.Name, &i.Type, &i.Status, diff --git a/app/backend/internal/database/sqlcdb/models.go b/app/backend/internal/database/sqlcdb/models.go index 8c8ed01..0d39eb3 100644 --- a/app/backend/internal/database/sqlcdb/models.go +++ b/app/backend/internal/database/sqlcdb/models.go @@ -154,6 +154,7 @@ type Project struct { Name string `json:"name"` Description sql.NullString `json:"description"` OwnerID uuid.UUID `json:"owner_id"` + IsApproved bool `json:"is_approved"` CreatedAt sql.NullTime `json:"created_at"` UpdatedAt sql.NullTime `json:"updated_at"` } @@ -205,6 +206,7 @@ type ServiceTemplate struct { Config json.RawMessage `json:"config"` Variables pqtype.NullRawMessage `json:"variables"` IsOfficial sql.NullBool `json:"is_official"` + OwnerID uuid.NullUUID `json:"owner_id"` CreatedAt sql.NullTime `json:"created_at"` UpdatedAt sql.NullTime `json:"updated_at"` } diff --git a/app/backend/internal/database/sqlcdb/projects.sql.go b/app/backend/internal/database/sqlcdb/projects.sql.go index 1c5e6f5..7625f49 100644 --- a/app/backend/internal/database/sqlcdb/projects.sql.go +++ b/app/backend/internal/database/sqlcdb/projects.sql.go @@ -16,25 +16,31 @@ const countProjectsByUser = `-- name: CountProjectsByUser :one SELECT COUNT(*)::bigint AS total FROM projects p WHERE - (p.owner_id = $1 OR EXISTS ( - SELECT 1 - FROM project_members pm - WHERE pm.project_id = p.id AND pm.user_id = $1 - )) + ( + p.is_approved + OR p.owner_id = $1 + OR $2::bool + OR EXISTS ( + SELECT 1 + FROM project_members pm + WHERE pm.project_id = p.id AND pm.user_id = $1 + ) + ) AND ( - $2::text IS NULL - OR p.name ILIKE ('%' || $2::text || '%') - OR COALESCE(p.description, '') ILIKE ('%' || $2::text || '%') + $3::text IS NULL + OR p.name ILIKE ('%' || $3::text || '%') + OR COALESCE(p.description, '') ILIKE ('%' || $3::text || '%') ) ` type CountProjectsByUserParams struct { - UserID uuid.UUID `json:"user_id"` - Search sql.NullString `json:"search"` + UserID uuid.UUID `json:"user_id"` + IsAdmin bool `json:"is_admin"` + Search sql.NullString `json:"search"` } func (q *Queries) CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error) { - row := q.db.QueryRowContext(ctx, countProjectsByUser, arg.UserID, arg.Search) + row := q.db.QueryRowContext(ctx, countProjectsByUser, arg.UserID, arg.IsAdmin, arg.Search) var total int64 err := row.Scan(&total) return total, err @@ -43,7 +49,7 @@ func (q *Queries) CountProjectsByUser(ctx context.Context, arg CountProjectsByUs const createProject = `-- name: CreateProject :one INSERT INTO projects (name, description, owner_id) VALUES ($1, $2, $3) -RETURNING id, name, description, owner_id, created_at, updated_at +RETURNING id, name, description, owner_id, is_approved, created_at, updated_at ` type CreateProjectParams struct { @@ -60,6 +66,7 @@ func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (P &i.Name, &i.Description, &i.OwnerID, + &i.IsApproved, &i.CreatedAt, &i.UpdatedAt, ) @@ -80,11 +87,13 @@ func (q *Queries) DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (i } const getProjectByIDForUser = `-- name: GetProjectByIDForUser :one -SELECT p.id, p.name, p.description, p.owner_id, p.created_at, p.updated_at +SELECT p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at FROM projects p WHERE p.id = $1 AND ( - p.owner_id = $2 + p.is_approved + OR p.owner_id = $2 + OR $3::bool OR EXISTS ( SELECT 1 FROM project_members pm @@ -96,16 +105,18 @@ WHERE p.id = $1 type GetProjectByIDForUserParams struct { ProjectID uuid.UUID `json:"project_id"` UserID uuid.UUID `json:"user_id"` + IsAdmin bool `json:"is_admin"` } func (q *Queries) GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDForUserParams) (Project, error) { - row := q.db.QueryRowContext(ctx, getProjectByIDForUser, arg.ProjectID, arg.UserID) + row := q.db.QueryRowContext(ctx, getProjectByIDForUser, arg.ProjectID, arg.UserID, arg.IsAdmin) var i Project err := row.Scan( &i.ID, &i.Name, &i.Description, &i.OwnerID, + &i.IsApproved, &i.CreatedAt, &i.UpdatedAt, ) @@ -127,21 +138,22 @@ func (q *Queries) GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID) const getProjectRoleForUser = `-- name: GetProjectRoleForUser :one SELECT (CASE - WHEN p.owner_id = $1 THEN 'owner' + WHEN p.owner_id = $1 OR $2::bool THEN 'owner' ELSE COALESCE(pm.role, '') END)::text AS role FROM projects p LEFT JOIN project_members pm ON p.id = pm.project_id AND pm.user_id = $1 -WHERE p.id = $2 +WHERE p.id = $3 ` type GetProjectRoleForUserParams struct { UserID uuid.UUID `json:"user_id"` + IsAdmin bool `json:"is_admin"` ProjectID uuid.UUID `json:"project_id"` } func (q *Queries) GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error) { - row := q.db.QueryRowContext(ctx, getProjectRoleForUser, arg.UserID, arg.ProjectID) + row := q.db.QueryRowContext(ctx, getProjectRoleForUser, arg.UserID, arg.IsAdmin, arg.ProjectID) var role string err := row.Scan(&role) return role, err @@ -162,12 +174,52 @@ func (q *Queries) InsertProjectEnvironment(ctx context.Context, arg InsertProjec return err } +const listPendingProjects = `-- name: ListPendingProjects :many +SELECT p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at +FROM projects p +WHERE p.is_approved = false +ORDER BY p.created_at DESC +` + +func (q *Queries) ListPendingProjects(ctx context.Context) ([]Project, error) { + rows, err := q.db.QueryContext(ctx, listPendingProjects) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Project{} + for rows.Next() { + var i Project + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.OwnerID, + &i.IsApproved, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listProjectsWithStatsByUser = `-- name: ListProjectsWithStatsByUser :many + SELECT p.id, p.name, p.description, p.owner_id, + p.is_approved, p.created_at, p.updated_at, COUNT(DISTINCT s.id)::bigint AS service_count, @@ -185,23 +237,29 @@ FROM projects p LEFT JOIN services s ON s.project_id = p.id LEFT JOIN deployments d ON d.service_id = s.id WHERE - (p.owner_id = $1 OR EXISTS ( - SELECT 1 - FROM project_members pm - WHERE pm.project_id = p.id AND pm.user_id = $1 - )) - AND ( - $2::text IS NULL - OR p.name ILIKE ('%' || $2::text || '%') - OR COALESCE(p.description, '') ILIKE ('%' || $2::text || '%') + ( + p.is_approved + OR p.owner_id = $1 + OR $2::bool + OR EXISTS ( + SELECT 1 + FROM project_members pm + WHERE pm.project_id = p.id AND pm.user_id = $1 + ) ) -GROUP BY p.id, p.name, p.description, p.owner_id, p.created_at, p.updated_at + AND ( + $3::text IS NULL + OR p.name ILIKE ('%' || $3::text || '%') + OR COALESCE(p.description, '') ILIKE ('%' || $3::text || '%') + ) +GROUP BY p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at ORDER BY p.updated_at DESC -LIMIT $4 OFFSET $3 +LIMIT $5 OFFSET $4 ` type ListProjectsWithStatsByUserParams struct { UserID uuid.UUID `json:"user_id"` + IsAdmin bool `json:"is_admin"` Search sql.NullString `json:"search"` OffsetCount int32 `json:"offset_count"` LimitCount int32 `json:"limit_count"` @@ -212,6 +270,7 @@ type ListProjectsWithStatsByUserRow struct { Name string `json:"name"` Description sql.NullString `json:"description"` OwnerID uuid.UUID `json:"owner_id"` + IsApproved bool `json:"is_approved"` CreatedAt sql.NullTime `json:"created_at"` UpdatedAt sql.NullTime `json:"updated_at"` ServiceCount int64 `json:"service_count"` @@ -220,9 +279,13 @@ type ListProjectsWithStatsByUserRow struct { LastDeployment sql.NullTime `json:"last_deployment"` } +// Visibility model: a project is readable when it is approved for public +// display, when the caller owns or is a member of it, or when the caller is a +// platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false. func (q *Queries) ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error) { rows, err := q.db.QueryContext(ctx, listProjectsWithStatsByUser, arg.UserID, + arg.IsAdmin, arg.Search, arg.OffsetCount, arg.LimitCount, @@ -239,6 +302,7 @@ func (q *Queries) ListProjectsWithStatsByUser(ctx context.Context, arg ListProje &i.Name, &i.Description, &i.OwnerID, + &i.IsApproved, &i.CreatedAt, &i.UpdatedAt, &i.ServiceCount, @@ -259,6 +323,25 @@ func (q *Queries) ListProjectsWithStatsByUser(ctx context.Context, arg ListProje return items, nil } +const setProjectApproved = `-- name: SetProjectApproved :execrows +UPDATE projects +SET is_approved = $1, updated_at = NOW() +WHERE id = $2 +` + +type SetProjectApprovedParams struct { + IsApproved bool `json:"is_approved"` + ProjectID uuid.UUID `json:"project_id"` +} + +func (q *Queries) SetProjectApproved(ctx context.Context, arg SetProjectApprovedParams) (int64, error) { + result, err := q.db.ExecContext(ctx, setProjectApproved, arg.IsApproved, arg.ProjectID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const updateProjectByID = `-- name: UpdateProjectByID :execrows UPDATE projects SET diff --git a/app/backend/internal/database/sqlcdb/querier.go b/app/backend/internal/database/sqlcdb/querier.go index 7d03186..1f1ef83 100644 --- a/app/backend/internal/database/sqlcdb/querier.go +++ b/app/backend/internal/database/sqlcdb/querier.go @@ -25,10 +25,12 @@ type Querier interface { CreateNotification(ctx context.Context, arg CreateNotificationParams) error CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error) CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error + CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error) DeleteAgent(ctx context.Context, id string) error DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error) + DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error) GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error) GetAgent(ctx context.Context, id string) (NodeAgent, error) GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAndIPParams) (NodeAgent, error) @@ -36,7 +38,9 @@ type Querier interface { GetContainer(ctx context.Context, id string) (ContainerInstance, error) GetContainerForAgent(ctx context.Context, arg GetContainerForAgentParams) (ContainerInstance, error) GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, arg GetDatabaseBackupByIDAndDatabaseAndUserParams) (DatabaseBackup, error) - GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (GetDatabaseServiceByIDAndUserRow, error) + GetDatabaseServiceByID(ctx context.Context, id string) (DatabaseService, error) + GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (DatabaseService, error) + GetDatabaseServiceOwnerID(ctx context.Context, id string) (string, error) GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDForUserParams) (Project, error) GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID) (uuid.UUID, error) GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) @@ -47,16 +51,21 @@ type Querier interface { ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error) ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error) ListAgents(ctx context.Context) ([]NodeAgent, error) + ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error) ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error) ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error) - ListDatabaseServicesByUser(ctx context.Context, userID string) ([]ListDatabaseServicesByUserRow, error) + ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error) ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error) ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error) ListPendingCommands(ctx context.Context, nodeAgentID string) ([]AgentCommand, error) + ListPendingProjects(ctx context.Context) ([]Project, error) + // Visibility model: a project is readable when it is approved for public + // display, when the caller owns or is a member of it, or when the caller is a + // platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false. ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error) - ListServiceTemplates(ctx context.Context) ([]ServiceTemplate, error) - ListServiceTemplatesByCategory(ctx context.Context, category string) ([]ServiceTemplate, error) + ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error) + ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error) MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error) @@ -66,6 +75,7 @@ type Querier interface { SetDatabaseServiceStatusAndConnectionByID(ctx context.Context, arg SetDatabaseServiceStatusAndConnectionByIDParams) error SetDatabaseServiceStatusByID(ctx context.Context, arg SetDatabaseServiceStatusByIDParams) error SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error + SetProjectApproved(ctx context.Context, arg SetProjectApprovedParams) (int64, error) TouchAgentAuthToken(ctx context.Context, tokenHash string) error UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error) UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error @@ -74,7 +84,9 @@ type Querier interface { UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error UpdateDatabaseServicePlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServicePlanByIDAndUserParams) error UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (int64, error) + UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplateParams) (int64, error) UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvironmentVariableParams) error + UpsertServiceTemplate(ctx context.Context, arg UpsertServiceTemplateParams) error } var _ Querier = (*Queries)(nil) diff --git a/app/backend/internal/database/sqlcdb/templates.sql.go b/app/backend/internal/database/sqlcdb/templates.sql.go index c9ba889..526b650 100644 --- a/app/backend/internal/database/sqlcdb/templates.sql.go +++ b/app/backend/internal/database/sqlcdb/templates.sql.go @@ -8,8 +8,10 @@ package sqlcdb import ( "context" "database/sql" + "encoding/json" "github.com/google/uuid" + "github.com/sqlc-dev/pqtype" ) const countServicesByProjectAndName = `-- name: CountServicesByProjectAndName :one @@ -122,6 +124,66 @@ func (q *Queries) CreateServiceFromTemplate(ctx context.Context, arg CreateServi return err } +const createUserTemplate = `-- name: CreateUserTemplate :exec +INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official, owner_id) +VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + false, + $8 +) +` + +type CreateUserTemplateParams struct { + ID string `json:"id"` + Name string `json:"name"` + Description sql.NullString `json:"description"` + Category string `json:"category"` + Logo sql.NullString `json:"logo"` + Config json.RawMessage `json:"config"` + Variables pqtype.NullRawMessage `json:"variables"` + OwnerID uuid.NullUUID `json:"owner_id"` +} + +func (q *Queries) CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error { + _, err := q.db.ExecContext(ctx, createUserTemplate, + arg.ID, + arg.Name, + arg.Description, + arg.Category, + arg.Logo, + arg.Config, + arg.Variables, + arg.OwnerID, + ) + return err +} + +const deleteUserTemplate = `-- name: DeleteUserTemplate :execrows +DELETE FROM service_templates +WHERE id = $1 + AND owner_id = $2 + AND is_official = false +` + +type DeleteUserTemplateParams struct { + ID string `json:"id"` + OwnerID uuid.NullUUID `json:"owner_id"` +} + +func (q *Queries) DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteUserTemplate, arg.ID, arg.OwnerID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const getProjectOwnerID = `-- name: GetProjectOwnerID :one SELECT owner_id FROM projects @@ -136,7 +198,7 @@ func (q *Queries) GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUI } const getServiceTemplateByID = `-- name: GetServiceTemplateByID :one -SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at +SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at FROM service_templates WHERE id = $1 ` @@ -153,20 +215,28 @@ func (q *Queries) GetServiceTemplateByID(ctx context.Context, id string) (Servic &i.Config, &i.Variables, &i.IsOfficial, + &i.OwnerID, &i.CreatedAt, &i.UpdatedAt, ) return i, err } -const listServiceTemplates = `-- name: ListServiceTemplates :many -SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at +const listServiceTemplatesByCategoryForUser = `-- name: ListServiceTemplatesByCategoryForUser :many +SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at FROM service_templates +WHERE category = $1 + AND (is_official = true OR owner_id = $2) ORDER BY is_official DESC, name ASC ` -func (q *Queries) ListServiceTemplates(ctx context.Context) ([]ServiceTemplate, error) { - rows, err := q.db.QueryContext(ctx, listServiceTemplates) +type ListServiceTemplatesByCategoryForUserParams struct { + Category string `json:"category"` + OwnerID uuid.NullUUID `json:"owner_id"` +} + +func (q *Queries) ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error) { + rows, err := q.db.QueryContext(ctx, listServiceTemplatesByCategoryForUser, arg.Category, arg.OwnerID) if err != nil { return nil, err } @@ -183,6 +253,7 @@ func (q *Queries) ListServiceTemplates(ctx context.Context) ([]ServiceTemplate, &i.Config, &i.Variables, &i.IsOfficial, + &i.OwnerID, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -199,15 +270,15 @@ func (q *Queries) ListServiceTemplates(ctx context.Context) ([]ServiceTemplate, return items, nil } -const listServiceTemplatesByCategory = `-- name: ListServiceTemplatesByCategory :many -SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at +const listServiceTemplatesForUser = `-- name: ListServiceTemplatesForUser :many +SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at FROM service_templates -WHERE category = $1 +WHERE is_official = true OR owner_id = $1 ORDER BY is_official DESC, name ASC ` -func (q *Queries) ListServiceTemplatesByCategory(ctx context.Context, category string) ([]ServiceTemplate, error) { - rows, err := q.db.QueryContext(ctx, listServiceTemplatesByCategory, category) +func (q *Queries) ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error) { + rows, err := q.db.QueryContext(ctx, listServiceTemplatesForUser, ownerID) if err != nil { return nil, err } @@ -224,6 +295,7 @@ func (q *Queries) ListServiceTemplatesByCategory(ctx context.Context, category s &i.Config, &i.Variables, &i.IsOfficial, + &i.OwnerID, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -240,6 +312,48 @@ func (q *Queries) ListServiceTemplatesByCategory(ctx context.Context, category s return items, nil } +const updateUserTemplate = `-- name: UpdateUserTemplate :execrows +UPDATE service_templates +SET name = $1, + description = $2, + category = $3, + logo = $4, + config = $5, + variables = $6, + updated_at = NOW() +WHERE id = $7 + AND owner_id = $8 + AND is_official = false +` + +type UpdateUserTemplateParams struct { + Name string `json:"name"` + Description sql.NullString `json:"description"` + Category string `json:"category"` + Logo sql.NullString `json:"logo"` + Config json.RawMessage `json:"config"` + Variables pqtype.NullRawMessage `json:"variables"` + ID string `json:"id"` + OwnerID uuid.NullUUID `json:"owner_id"` +} + +func (q *Queries) UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplateParams) (int64, error) { + result, err := q.db.ExecContext(ctx, updateUserTemplate, + arg.Name, + arg.Description, + arg.Category, + arg.Logo, + arg.Config, + arg.Variables, + arg.ID, + arg.OwnerID, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const upsertEnvironmentVariable = `-- name: UpsertEnvironmentVariable :exec INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7) @@ -271,3 +385,51 @@ func (q *Queries) UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvir ) return err } + +const upsertServiceTemplate = `-- name: UpsertServiceTemplate :exec +INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official) +VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8 +) +ON CONFLICT (id) DO UPDATE +SET name = EXCLUDED.name, + description = EXCLUDED.description, + category = EXCLUDED.category, + logo = EXCLUDED.logo, + config = EXCLUDED.config, + variables = EXCLUDED.variables, + is_official = EXCLUDED.is_official, + updated_at = NOW() +` + +type UpsertServiceTemplateParams struct { + ID string `json:"id"` + Name string `json:"name"` + Description sql.NullString `json:"description"` + Category string `json:"category"` + Logo sql.NullString `json:"logo"` + Config json.RawMessage `json:"config"` + Variables pqtype.NullRawMessage `json:"variables"` + IsOfficial sql.NullBool `json:"is_official"` +} + +func (q *Queries) UpsertServiceTemplate(ctx context.Context, arg UpsertServiceTemplateParams) error { + _, err := q.db.ExecContext(ctx, upsertServiceTemplate, + arg.ID, + arg.Name, + arg.Description, + arg.Category, + arg.Logo, + arg.Config, + arg.Variables, + arg.IsOfficial, + ) + return err +} diff --git a/app/backend/internal/middleware/middleware.go b/app/backend/internal/middleware/middleware.go index 012daa8..dafbd8c 100644 --- a/app/backend/internal/middleware/middleware.go +++ b/app/backend/internal/middleware/middleware.go @@ -69,41 +69,72 @@ func RequestID() gin.HandlerFunc { } } +// resolveRequestUser authenticates the request via Bearer JWT or the Better +// Auth session cookie. On success it returns the local user id and email. +// tokenErr/hasToken mirror the legacy error contract for Auth. +func resolveRequestUser(c *gin.Context, jwtSecret string, sessionVerifier *betterAuthSessionVerifier) (userID, email, tokenErr string, hasToken, ok bool) { + tokenString, tokenErr, hasToken := extractJWTToken(c) + if tokenString != "" { + if claims, valid := validateJWTClaims(tokenString, jwtSecret); valid { + userIDClaim, exists := claims["user_id"] + if exists { + id := strings.TrimSpace(fmt.Sprint(userIDClaim)) + if _, err := uuid.Parse(id); err == nil { + if emailClaim, exists := claims["email"]; exists && emailClaim != nil { + email = strings.TrimSpace(fmt.Sprint(emailClaim)) + } + return id, email, "", hasToken, true + } + } + tokenErr = "Invalid token claims" + } else if tokenErr == "" { + tokenErr = "Invalid token" + } + } + + if sessionVerifier != nil { + if id, mail, verified := sessionVerifier.resolveUser(c); verified { + return id, mail, "", hasToken, true + } + } + + return "", "", tokenErr, hasToken, false +} + +// setAuthenticatedContext stores the resolved identity plus the admin flag on +// the gin context so handlers and RequireAdmin can rely on it. +func setAuthenticatedContext(c *gin.Context, userID, email string) { + c.Set("user_id", userID) + c.Set("email", email) + c.Set("is_admin", loadIsAdmin(c, userID)) +} + +func loadIsAdmin(c *gin.Context, userID string) bool { + dbValue, exists := c.Get("db") + if !exists { + return false + } + db, ok := dbValue.(*database.DB) + if !ok || db == nil || db.DB == nil { + return false + } + var isAdmin bool + if err := db.QueryRow(`SELECT is_admin FROM users WHERE id = $1`, userID).Scan(&isAdmin); err != nil { + return false + } + return isAdmin +} + // Auth middleware for JWT authentication func Auth(jwtSecret string) gin.HandlerFunc { sessionVerifier := newBetterAuthSessionVerifier() return func(c *gin.Context) { - tokenString, tokenErr, hasToken := extractJWTToken(c) - if tokenString != "" { - if claims, valid := validateJWTClaims(tokenString, jwtSecret); valid { - userIDClaim, exists := claims["user_id"] - if exists { - userID := strings.TrimSpace(fmt.Sprint(userIDClaim)) - if _, err := uuid.Parse(userID); err == nil { - email := "" - if emailClaim, ok := claims["email"]; ok && emailClaim != nil { - email = strings.TrimSpace(fmt.Sprint(emailClaim)) - } - c.Set("user_id", userID) - c.Set("email", email) - c.Next() - return - } - } - tokenErr = "Invalid token claims" - } else if tokenErr == "" { - tokenErr = "Invalid token" - } - } - - if sessionVerifier != nil { - if userID, email, ok := sessionVerifier.resolveUser(c); ok { - c.Set("user_id", userID) - c.Set("email", email) - c.Next() - return - } + userID, email, tokenErr, hasToken, ok := resolveRequestUser(c, jwtSecret, sessionVerifier) + if ok { + setAuthenticatedContext(c, userID, email) + c.Next() + return } if tokenErr != "" { @@ -121,6 +152,38 @@ func Auth(jwtSecret string) gin.HandlerFunc { } } +// OptionalAuth resolves identity when a valid credential is present but never +// rejects the request — anonymous callers simply get no user context. Read +// endpoints hang off this so the public site can browse approved resources. +func OptionalAuth(jwtSecret string) gin.HandlerFunc { + sessionVerifier := newBetterAuthSessionVerifier() + + return func(c *gin.Context) { + if userID, email, _, _, ok := resolveRequestUser(c, jwtSecret, sessionVerifier); ok { + setAuthenticatedContext(c, userID, email) + } + c.Next() + } +} + +// RequireAdmin rejects requests whose resolved user is not a platform admin. +// Must run after Auth (or OptionalAuth) so user_id/is_admin are populated. +func RequireAdmin() gin.HandlerFunc { + return func(c *gin.Context) { + if _, exists := c.Get("user_id"); !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + c.Abort() + return + } + if !c.GetBool("is_admin") { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"}) + c.Abort() + return + } + c.Next() + } +} + type betterAuthSessionVerifier struct { internalURL string internalToken string diff --git a/app/backend/migrations_goose/20260924000000_public_platform.sql b/app/backend/migrations_goose/20260924000000_public_platform.sql new file mode 100644 index 0000000..3dedc2d --- /dev/null +++ b/app/backend/migrations_goose/20260924000000_public_platform.sql @@ -0,0 +1,16 @@ +-- +goose Up +-- Public platform model: projects are browsable without a session once the +-- admin approves them; service_templates gains ownership for user templates. +ALTER TABLE public.projects ADD COLUMN IF NOT EXISTS is_approved boolean NOT NULL DEFAULT false; + +-- Projects that already exist stay live; only newly created ones enter the +-- approval queue. +UPDATE public.projects SET is_approved = true; + +ALTER TABLE public.service_templates ADD COLUMN IF NOT EXISTS owner_id uuid; +CREATE INDEX IF NOT EXISTS idx_service_templates_owner ON public.service_templates(owner_id); + +-- +goose Down +ALTER TABLE public.projects DROP COLUMN IF EXISTS is_approved; +DROP INDEX IF EXISTS public.idx_service_templates_owner; +ALTER TABLE public.service_templates DROP COLUMN IF EXISTS owner_id; diff --git a/app/backend/sqlc/queries/databases.sql b/app/backend/sqlc/queries/databases.sql index a5a6f21..7bd1bd7 100644 --- a/app/backend/sqlc/queries/databases.sql +++ b/app/backend/sqlc/queries/databases.sql @@ -1,11 +1,26 @@ -- name: ListDatabaseServicesByUser :many -SELECT id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at +SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at FROM database_services WHERE user_id = $1 ORDER BY created_at DESC; +-- name: ListAllDatabaseServices :many +SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at +FROM database_services +ORDER BY created_at DESC; + +-- name: GetDatabaseServiceByID :one +SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at +FROM database_services +WHERE id = $1; + +-- name: GetDatabaseServiceOwnerID :one +SELECT user_id +FROM database_services +WHERE id = $1; + -- name: GetDatabaseServiceByIDAndUser :one -SELECT id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at +SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at FROM database_services WHERE id = $1 AND user_id = $2; diff --git a/app/backend/sqlc/queries/projects.sql b/app/backend/sqlc/queries/projects.sql index 5ac83a8..88a949c 100644 --- a/app/backend/sqlc/queries/projects.sql +++ b/app/backend/sqlc/queries/projects.sql @@ -1,9 +1,14 @@ +-- Visibility model: a project is readable when it is approved for public +-- display, when the caller owns or is a member of it, or when the caller is a +-- platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false. + -- name: ListProjectsWithStatsByUser :many SELECT p.id, p.name, p.description, p.owner_id, + p.is_approved, p.created_at, p.updated_at, COUNT(DISTINCT s.id)::bigint AS service_count, @@ -21,17 +26,22 @@ FROM projects p LEFT JOIN services s ON s.project_id = p.id LEFT JOIN deployments d ON d.service_id = s.id WHERE - (p.owner_id = sqlc.arg(user_id) OR EXISTS ( - SELECT 1 - FROM project_members pm - WHERE pm.project_id = p.id AND pm.user_id = sqlc.arg(user_id) - )) + ( + p.is_approved + OR p.owner_id = sqlc.arg(user_id) + OR sqlc.arg(is_admin)::bool + OR EXISTS ( + SELECT 1 + FROM project_members pm + WHERE pm.project_id = p.id AND pm.user_id = sqlc.arg(user_id) + ) + ) AND ( sqlc.narg(search)::text IS NULL OR p.name ILIKE ('%' || sqlc.narg(search)::text || '%') OR COALESCE(p.description, '') ILIKE ('%' || sqlc.narg(search)::text || '%') ) -GROUP BY p.id, p.name, p.description, p.owner_id, p.created_at, p.updated_at +GROUP BY p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at ORDER BY p.updated_at DESC LIMIT sqlc.arg(limit_count) OFFSET sqlc.arg(offset_count); @@ -39,11 +49,16 @@ LIMIT sqlc.arg(limit_count) OFFSET sqlc.arg(offset_count); SELECT COUNT(*)::bigint AS total FROM projects p WHERE - (p.owner_id = sqlc.arg(user_id) OR EXISTS ( - SELECT 1 - FROM project_members pm - WHERE pm.project_id = p.id AND pm.user_id = sqlc.arg(user_id) - )) + ( + p.is_approved + OR p.owner_id = sqlc.arg(user_id) + OR sqlc.arg(is_admin)::bool + OR EXISTS ( + SELECT 1 + FROM project_members pm + WHERE pm.project_id = p.id AND pm.user_id = sqlc.arg(user_id) + ) + ) AND ( sqlc.narg(search)::text IS NULL OR p.name ILIKE ('%' || sqlc.narg(search)::text || '%') @@ -51,11 +66,13 @@ WHERE ); -- name: GetProjectByIDForUser :one -SELECT p.id, p.name, p.description, p.owner_id, p.created_at, p.updated_at +SELECT p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at FROM projects p WHERE p.id = sqlc.arg(project_id) AND ( - p.owner_id = sqlc.arg(user_id) + p.is_approved + OR p.owner_id = sqlc.arg(user_id) + OR sqlc.arg(is_admin)::bool OR EXISTS ( SELECT 1 FROM project_members pm @@ -66,7 +83,7 @@ WHERE p.id = sqlc.arg(project_id) -- name: CreateProject :one INSERT INTO projects (name, description, owner_id) VALUES (sqlc.arg(name), sqlc.narg(description), sqlc.arg(owner_id)) -RETURNING id, name, description, owner_id, created_at, updated_at; +RETURNING id, name, description, owner_id, is_approved, created_at, updated_at; -- name: InsertProjectEnvironment :exec INSERT INTO environments (name, project_id) @@ -74,7 +91,7 @@ VALUES (sqlc.arg(name), sqlc.arg(project_id)); -- name: GetProjectRoleForUser :one SELECT (CASE - WHEN p.owner_id = sqlc.arg(user_id) THEN 'owner' + WHEN p.owner_id = sqlc.arg(user_id) OR sqlc.arg(is_admin)::bool THEN 'owner' ELSE COALESCE(pm.role, '') END)::text AS role FROM projects p @@ -97,3 +114,14 @@ WHERE id = sqlc.arg(project_id); -- name: DeleteProjectByID :execrows DELETE FROM projects WHERE id = sqlc.arg(project_id); + +-- name: SetProjectApproved :execrows +UPDATE projects +SET is_approved = sqlc.arg(is_approved), updated_at = NOW() +WHERE id = sqlc.arg(project_id); + +-- name: ListPendingProjects :many +SELECT p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at +FROM projects p +WHERE p.is_approved = false +ORDER BY p.created_at DESC; diff --git a/app/backend/sqlc/queries/templates.sql b/app/backend/sqlc/queries/templates.sql index 1bafe20..25d2de4 100644 --- a/app/backend/sqlc/queries/templates.sql +++ b/app/backend/sqlc/queries/templates.sql @@ -1,19 +1,76 @@ --- name: ListServiceTemplates :many -SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at +-- name: ListServiceTemplatesForUser :many +SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at FROM service_templates +WHERE is_official = true OR owner_id = sqlc.narg(owner_id) ORDER BY is_official DESC, name ASC; --- name: ListServiceTemplatesByCategory :many -SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at +-- name: ListServiceTemplatesByCategoryForUser :many +SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at FROM service_templates -WHERE category = $1 +WHERE category = sqlc.arg(category) + AND (is_official = true OR owner_id = sqlc.narg(owner_id)) ORDER BY is_official DESC, name ASC; -- name: GetServiceTemplateByID :one -SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at +SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at FROM service_templates WHERE id = $1; +-- name: UpsertServiceTemplate :exec +INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official) +VALUES ( + sqlc.arg(id), + sqlc.arg(name), + sqlc.narg(description), + sqlc.arg(category), + sqlc.narg(logo), + sqlc.arg(config), + sqlc.narg(variables), + sqlc.arg(is_official) +) +ON CONFLICT (id) DO UPDATE +SET name = EXCLUDED.name, + description = EXCLUDED.description, + category = EXCLUDED.category, + logo = EXCLUDED.logo, + config = EXCLUDED.config, + variables = EXCLUDED.variables, + is_official = EXCLUDED.is_official, + updated_at = NOW(); + +-- name: CreateUserTemplate :exec +INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official, owner_id) +VALUES ( + sqlc.arg(id), + sqlc.arg(name), + sqlc.narg(description), + sqlc.arg(category), + sqlc.narg(logo), + sqlc.arg(config), + sqlc.narg(variables), + false, + sqlc.arg(owner_id) +); + +-- name: UpdateUserTemplate :execrows +UPDATE service_templates +SET name = sqlc.arg(name), + description = sqlc.narg(description), + category = sqlc.arg(category), + logo = sqlc.narg(logo), + config = sqlc.arg(config), + variables = sqlc.narg(variables), + updated_at = NOW() +WHERE id = sqlc.arg(id) + AND owner_id = sqlc.arg(owner_id) + AND is_official = false; + +-- name: DeleteUserTemplate :execrows +DELETE FROM service_templates +WHERE id = sqlc.arg(id) + AND owner_id = sqlc.arg(owner_id) + AND is_official = false; + -- name: GetProjectOwnerID :one SELECT owner_id FROM projects diff --git a/app/backend/sqlc/schema.sql b/app/backend/sqlc/schema.sql index 67a38c7..a4e69bf 100644 --- a/app/backend/sqlc/schema.sql +++ b/app/backend/sqlc/schema.sql @@ -3,6 +3,7 @@ CREATE TABLE projects ( name VARCHAR(255) NOT NULL, description TEXT, owner_id UUID NOT NULL, + is_approved BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); @@ -82,6 +83,7 @@ CREATE TABLE service_templates ( config JSONB NOT NULL, variables JSONB DEFAULT '[]', is_official BOOLEAN DEFAULT false, + owner_id UUID, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); diff --git a/app/frontend/package-lock.json b/app/frontend/package-lock.json index 92c9a30..06c1088 100644 --- a/app/frontend/package-lock.json +++ b/app/frontend/package-lock.json @@ -32,6 +32,7 @@ "@tailwindcss/postcss": "^4.1.18", "@tailwindcss/typography": "^0.5.19", "@tanstack/react-query": "^5.66.0", + "@types/dompurify": "^3.0.5", "@xyflow/react": "^12.10.0", "autoprefixer": "^10.4.24", "axios": "^1.7.9", @@ -40,7 +41,9 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", + "dompurify": "^3.2.6", "lucide-react": "^0.563.0", + "marked": "^18.0.13", "postcss": "^8.5.6", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -3719,6 +3722,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -3763,6 +3775,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -5205,6 +5223,15 @@ "license": "MIT", "peer": true }, + "node_modules/dompurify": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz", + "integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -6577,6 +6604,18 @@ "node": ">=10" } }, + "node_modules/marked": { + "version": "18.0.13", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.13.tgz", + "integrity": "sha512-xTxVzZsBFwunP6HDmtBkabUQEYArnP7/rMDGmPj9SlrKlQ4i8MdYVow+nJL0eOqwpUqhzBoTBRADGN6uYwPyOw==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/app/frontend/package.json b/app/frontend/package.json index b1dc647..73c5952 100644 --- a/app/frontend/package.json +++ b/app/frontend/package.json @@ -7,6 +7,9 @@ "dev": "vite", "build": "vite build", "build:check": "tsc -b && vite build", + "predev": "node scripts/generate-docs-snapshot.mjs", + "prebuild": "node scripts/generate-docs-snapshot.mjs", + "generate:docs": "node scripts/generate-docs-snapshot.mjs", "generate:api": "openapi-typescript ../../docs/api/openapi.yaml -o src/generated/api-types.ts", "lint": "eslint .", "preview": "vite preview", @@ -40,6 +43,7 @@ "@tailwindcss/postcss": "^4.1.18", "@tailwindcss/typography": "^0.5.19", "@tanstack/react-query": "^5.66.0", + "@types/dompurify": "^3.0.5", "@xyflow/react": "^12.10.0", "autoprefixer": "^10.4.24", "axios": "^1.7.9", @@ -48,7 +52,9 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", + "dompurify": "^3.2.6", "lucide-react": "^0.563.0", + "marked": "^18.0.13", "postcss": "^8.5.6", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/app/frontend/scripts/generate-docs-snapshot.mjs b/app/frontend/scripts/generate-docs-snapshot.mjs new file mode 100644 index 0000000..6534cee --- /dev/null +++ b/app/frontend/scripts/generate-docs-snapshot.mjs @@ -0,0 +1,55 @@ +// Bundles the repository docs/ tree into a generated TS module so the docs +// browser renders instantly and offline. The GitHub sync layer refreshes it +// at runtime when network is available. +import { readdirSync, readFileSync, statSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..', '..'); +const docsDir = join(root, 'docs'); +const outFile = join(root, 'app/frontend/src/generated/docs-snapshot.ts'); + +// Directories that are internal working notes rather than user documentation. +const SKIP_DIRS = new Set(['archive', 'design', 'superpowers']); + +function walk(dir) { + const entries = []; + for (const name of readdirSync(dir)) { + const full = join(dir, name); + const stat = statSync(full); + if (stat.isDirectory()) { + if (!SKIP_DIRS.has(name)) entries.push(...walk(full)); + } else if (name.toLowerCase().endsWith('.md')) { + entries.push(full); + } + } + return entries; +} + +function titleOf(markdown, fallback) { + const match = markdown.match(/^#\s+(.+)$/m); + if (match) return match[1].replace(/[#*`]/g, '').trim(); + return fallback + .replace(/\.md$/i, '') + .replace(/[-_]+/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()); +} + +const files = walk(docsDir).sort(); +const docs = files.map((full) => { + const rel = relative(docsDir, full).replace(/\\/g, '/'); + const markdown = readFileSync(full, 'utf8'); + const section = rel.includes('/') ? rel.split('/')[0] : 'docs'; + return { + path: rel, + section, + title: titleOf(markdown, rel.split('/').pop() ?? rel), + markdown, + }; +}); + +const banner = `/**\n * Auto-generated by scripts/generate-docs-snapshot.mjs.\n * Bundled copy of the repository docs tree — the offline fallback for the\n * docs browser. Regenerate with \`npm run generate:docs\`.\n */\n\nexport type BundledDoc = {\n path: string;\n section: string;\n title: string;\n markdown: string;\n};\n\n`; + +mkdirSync(join(outFile, '..'), { recursive: true }); +writeFileSync(outFile, `${banner}export const bundledDocs: BundledDoc[] = ${JSON.stringify(docs, null, 2)};\n`); +console.log(`docs snapshot: ${docs.length} files → ${relative(root, outFile)}`); diff --git a/app/frontend/src/app/App.tsx b/app/frontend/src/app/App.tsx index 2a724a0..ef3444c 100644 --- a/app/frontend/src/app/App.tsx +++ b/app/frontend/src/app/App.tsx @@ -1,4 +1,6 @@ -import { Navigate, Outlet, Route, Routes, useLocation } from 'react-router-dom'; +import { Link, Navigate, Outlet, Route, Routes, useLocation } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { ShieldCheck } from 'lucide-react'; import { PlatformShell } from './layout/PlatformShell'; import { ProjectsPage } from '@/features/projects/pages/ProjectsPage'; import { ProjectWorkspacePage } from '@/features/workspace/pages/ProjectWorkspacePage'; @@ -7,6 +9,8 @@ import { ServiceMetricsDashboard } from '@/features/service/pages/ServiceMetrics import { BuildsPage } from '@/features/builds/pages/BuildsPage'; import { TemplatesPage } from '@/features/templates/pages/TemplatesPage'; import { AuditLogsPage } from '@/features/audit/pages/AuditLogsPage'; +import { AdminPage } from '@/features/admin/pages/AdminPage'; +import { LandingPage } from '@/features/landing/LandingPage'; import { DocsPage, PeoplePage, @@ -19,12 +23,17 @@ import { } from '@/features/secondary/pages'; import { SignInPage, SignUpPage } from '@/features/auth/pages'; import { useAuthSession } from '@/lib/use-auth-session'; +import { getCurrentUserProfile } from '@/lib/api-client'; import { ErrorBoundary, LoadingState } from '@/shared/components'; +function useDemoMode() { + const location = useLocation(); + return new URLSearchParams(location.search).get('demo') === '1'; +} + function AuthRequired() { const location = useLocation(); - const searchParams = new URLSearchParams(location.search); - const isDemoMode = searchParams.get('demo') === '1'; + const isDemoMode = useDemoMode(); const sessionQuery = useAuthSession({ enabled: !isDemoMode }); if (isDemoMode) { @@ -47,38 +56,98 @@ function AuthRequired() { return ; } +function AdminRequired() { + const location = useLocation(); + const isDemoMode = useDemoMode(); + const sessionQuery = useAuthSession({ enabled: !isDemoMode }); + const profileQuery = useQuery({ + queryKey: ['current-profile'], + queryFn: getCurrentUserProfile, + enabled: !isDemoMode && Boolean(sessionQuery.data), + retry: false, + }); + + if (isDemoMode) { + return ; + } + + if (sessionQuery.isPending || (sessionQuery.data && profileQuery.isPending)) { + return ( +
+ +
+ ); + } + + if (!sessionQuery.data) { + const redirect = `${location.pathname}${location.search}`; + return ; + } + + if (!profileQuery.data?.isAdmin) { + return ( +
+ +
+

Admin access required

+

+ This area is restricted to the platform administrator. +

+
+ + Back to home + +
+ ); + } + + return ; +} + export default function App() { return ( - } /> + } /> } /> } /> - }> - }> - } /> - } /> - } /> - } /> + {/* Public browsing — approved projects, templates, docs. */} + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> - } /> - } /> - } /> - } /> - } /> + {/* Authenticated areas */} + }> + } /> } /> } /> } /> - } /> } /> - {/* Full-bleed design demo — renders its own chrome, no app shell */} + {/* Platform owner only */} + }> + } /> + + + + {/* Full-bleed design demo — renders its own chrome, no app shell */} + }> } /> - } /> + } /> ); diff --git a/app/frontend/src/app/layout/PlatformShell.tsx b/app/frontend/src/app/layout/PlatformShell.tsx index cb5f956..241ef02 100644 --- a/app/frontend/src/app/layout/PlatformShell.tsx +++ b/app/frontend/src/app/layout/PlatformShell.tsx @@ -1,27 +1,35 @@ import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'; import { useEffect, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; import { Bell, BookOpen, ChartBar, CheckCircle2, + ChevronsLeft, Container, Database, + FileText, FolderKanban, LayoutTemplate, + LogIn, LogOut, Moon, + PanelLeft, RefreshCw, + ScrollText, Search, Settings, Shield, ShieldCheck, Sun, UploadCloud, + User, Users, } from 'lucide-react'; import { + getCurrentUserProfile, getUpgradeStatus, listNotifications, markAllNotificationsRead, @@ -32,25 +40,62 @@ import { signOutAuthSession } from '@/lib/auth-client'; import { useAuthSession } from '@/lib/use-auth-session'; import { useToast } from '@/shared/components'; -const navItems = [ - { label: 'Projects', href: '/projects', icon: FolderKanban }, - { label: 'Templates', href: '/templates', icon: LayoutTemplate }, - { label: 'Databases', href: '/databases', icon: Database }, - { label: 'HA', href: '/ha', icon: ShieldCheck }, - { label: 'Security', href: '/security', icon: Shield }, - { label: 'Usage', href: '/usage', icon: ChartBar }, - { label: 'People', href: '/people', icon: Users }, - { label: 'Settings', href: '/settings', icon: Settings }, - { label: 'Docs', href: '/docs', icon: BookOpen }, +type NavItem = { label: string; href: string; icon: typeof FolderKanban; hint: string }; +type NavSection = { title: string; items: NavItem[] }; + +const navSections: NavSection[] = [ + { + title: 'Build', + items: [ + { label: 'Projects', href: '/projects', icon: FolderKanban, hint: 'Services & deployments' }, + { label: 'Templates', href: '/templates', icon: LayoutTemplate, hint: 'Deployable presets' }, + { label: 'Builds', href: '/builds', icon: FileText, hint: 'Build history' }, + ], + }, + { + title: 'Operate', + items: [ + { label: 'Databases', href: '/databases', icon: Database, hint: 'Managed data services' }, + { label: 'High Availability', href: '/ha', icon: ShieldCheck, hint: 'Failover & health' }, + { label: 'Security', href: '/security', icon: Shield, hint: 'Scans & compliance' }, + { label: 'Usage', href: '/usage', icon: ChartBar, hint: 'Resource consumption' }, + ], + }, + { + title: 'Workspace', + items: [ + { label: 'People', href: '/people', icon: Users, hint: 'Members & access' }, + { label: 'Audit Logs', href: '/settings/audit-logs', icon: ScrollText, hint: 'Activity trail' }, + { label: 'Settings', href: '/settings', icon: Settings, hint: 'Platform preferences' }, + { label: 'Docs', href: '/docs', icon: BookOpen, hint: 'Guides & reference' }, + ], + }, ]; type ThemeMode = 'dark' | 'light'; -function getInitialTheme(): ThemeMode { - if (typeof window === 'undefined') { - return 'dark'; +function readStorage(key: string): string | null { + try { + return typeof window === 'undefined' ? null : window.localStorage.getItem(key); + } catch { + return null; } - return localStorage.getItem('containr.theme') === 'light' ? 'light' : 'dark'; +} + +function writeStorage(key: string, value: string) { + try { + window.localStorage.setItem(key, value); + } catch { + // Storage unavailable (SSR, tests, private mode) — non-persistent is fine. + } +} + +function getInitialTheme(): ThemeMode { + return readStorage('containr.theme') === 'light' ? 'light' : 'dark'; +} + +function getInitialSidebar(): boolean { + return readStorage('containr.sidebar.expanded') !== '0'; } export function PlatformShell() { @@ -61,9 +106,19 @@ export function PlatformShell() { const isDemoMode = new URLSearchParams(location.search).get('demo') === '1'; const href = (target: string) => (isDemoMode ? `${target}?demo=1` : target); const [theme, setTheme] = useState(() => getInitialTheme()); + const [sidebarExpanded, setSidebarExpanded] = useState(() => getInitialSidebar()); const [notificationsOpen, setNotificationsOpen] = useState(false); const sessionQuery = useAuthSession({ enabled: !isDemoMode }); + const signedIn = isDemoMode || Boolean(sessionQuery.data); + const profileQuery = useQuery({ + queryKey: ['current-profile'], + queryFn: getCurrentUserProfile, + enabled: !isDemoMode && Boolean(sessionQuery.data), + staleTime: 60_000, + retry: false, + }); + const isAdmin = isDemoMode || Boolean(profileQuery.data?.isAdmin); const upgradeQuery = useQuery({ queryKey: ['upgrade-status'], queryFn: getUpgradeStatus, @@ -72,7 +127,7 @@ export function PlatformShell() { const notificationsQuery = useQuery({ queryKey: ['shell-notifications'], queryFn: () => listNotifications(20), - enabled: !isDemoMode, + enabled: !isDemoMode && signedIn, refetchInterval: 30_000, }); const markReadMutation = useMutation({ @@ -86,9 +141,13 @@ export function PlatformShell() { useEffect(() => { document.documentElement.dataset.theme = theme; - localStorage.setItem('containr.theme', theme); + writeStorage('containr.theme', theme); }, [theme]); + useEffect(() => { + writeStorage('containr.sidebar.expanded', sidebarExpanded ? '1' : '0'); + }, [sidebarExpanded]); + const pullMutation = useMutation({ mutationFn: pullUpgradeImage, onSuccess: (status) => { @@ -115,8 +174,8 @@ export function PlatformShell() { const isActiveRoute = (itemHref: string) => location.pathname === itemHref || location.pathname.startsWith(`${itemHref}/`); - const userName = sessionQuery.data?.user.name ?? 'Account'; - const userEmail = sessionQuery.data?.user.email ?? 'Local session'; + const userName = profileQuery.data?.name ?? sessionQuery.data?.user.name ?? 'Account'; + const userEmail = profileQuery.data?.email ?? sessionQuery.data?.user.email ?? 'Local session'; const initials = userName .split(' ') .map((part) => part[0]) @@ -124,68 +183,177 @@ export function PlatformShell() { .slice(0, 2) .toUpperCase() || 'C'; + const avatar = ( +
+ {initials} +
+ ); + return (
@@ -225,93 +393,97 @@ export function PlatformShell() { {theme === 'dark' ? : } - - -
+ {isAdmin ? ( + ) : null} - {notificationsOpen ? ( -
-
-
-

Notifications

-

- {unreadCount > 0 ? `${unreadCount} unread` : 'All caught up'} -

-
-
- {unreadCount > 0 ? ( - - ) : null} - -
-
-
- {notifications.length > 0 ? ( - notifications.map((item) => ( - - )) - ) : ( -
- - No notifications yet + {signedIn ? ( +
+ + + {notificationsOpen ? ( +
+
+
+

Notifications

+

+ {unreadCount > 0 ? `${unreadCount} unread` : 'All caught up'} +

- )} +
+ {unreadCount > 0 ? ( + + ) : null} + +
+
+
+ {notifications.length > 0 ? ( + notifications.map((item) => ( + + )) + ) : ( +
+ + No notifications yet +
+ )} +
-
- ) : null} -
+ ) : null} +
+ ) : null}
@@ -332,7 +504,7 @@ export function PlatformShell() {
); } + +const menuItemClass = + 'flex w-full cursor-pointer select-none items-center gap-2 rounded-[var(--radius-md)] px-2.5 py-2 text-[13px] text-[var(--text-secondary)] outline-none transition-colors hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] data-[highlighted]:bg-[var(--surface-muted)] data-[highlighted]:text-[var(--text-primary)]'; diff --git a/app/frontend/src/features/admin/pages/AdminPage.tsx b/app/frontend/src/features/admin/pages/AdminPage.tsx new file mode 100644 index 0000000..8c6fb22 --- /dev/null +++ b/app/frontend/src/features/admin/pages/AdminPage.tsx @@ -0,0 +1,369 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { + CheckCircle2, + Clock, + FolderKanban, + Pencil, + ShieldCheck, + Trash2, + Users, + XCircle, +} from 'lucide-react'; +import { + deleteProject, + getAdminOverview, + listAdminUsers, + listProjects, + setProjectApproval, + setUserAdmin, + updateProject, + type AdminUser, + type ProjectEntity, +} from '@/lib/api-client'; +import { useAuthSession } from '@/lib/use-auth-session'; +import { LoadingState, useToast } from '@/shared/components'; + +const statLabels: Record = { + users: 'Users', + projects: 'Projects', + pending_projects: 'Pending approval', + services: 'Services', + running_services: 'Running', + databases: 'Databases', + agents: 'Agents', + templates: 'Templates', + user_templates: 'User templates', + cron_jobs: 'Cron jobs', + deployments_total: 'Deployments', +}; + +export function AdminPage() { + const queryClient = useQueryClient(); + const { showToast } = useToast(); + const sessionQuery = useAuthSession(); + const myId = sessionQuery.data?.user.id; + + const overviewQuery = useQuery({ queryKey: ['admin-overview'], queryFn: getAdminOverview }); + const usersQuery = useQuery({ queryKey: ['admin-users'], queryFn: listAdminUsers }); + const projectsQuery = useQuery({ queryKey: ['admin-projects'], queryFn: () => listProjects({ limit: 100 }) }); + const [editingProject, setEditingProject] = useState(null); + const [editForm, setEditForm] = useState({ name: '', description: '' }); + + const invalidate = () => { + queryClient.invalidateQueries({ queryKey: ['admin-overview'] }); + queryClient.invalidateQueries({ queryKey: ['admin-users'] }); + queryClient.invalidateQueries({ queryKey: ['admin-projects'] }); + queryClient.invalidateQueries({ queryKey: ['projects'] }); + }; + + const approvalMutation = useMutation({ + mutationFn: ({ id, approved }: { id: string; approved: boolean }) => setProjectApproval(id, approved), + onSuccess: (_, vars) => { + showToast('success', vars.approved ? 'Project approved' : 'Project unapproved'); + invalidate(); + }, + onError: (error) => showToast('error', 'Update failed', error instanceof Error ? error.message : undefined), + }); + + const adminMutation = useMutation({ + mutationFn: ({ id, isAdmin }: { id: string; isAdmin: boolean }) => setUserAdmin(id, isAdmin), + onSuccess: (_, vars) => { + showToast('success', vars.isAdmin ? 'Admin granted' : 'Admin revoked'); + invalidate(); + }, + onError: (error) => showToast('error', 'Update failed', error instanceof Error ? error.message : undefined), + }); + + const editMutation = useMutation({ + mutationFn: () => + updateProject(editingProject!.id, { + name: editForm.name.trim(), + description: editForm.description.trim(), + }), + onSuccess: () => { + showToast('success', 'Project updated'); + setEditingProject(null); + invalidate(); + }, + onError: (error) => showToast('error', 'Update failed', error instanceof Error ? error.message : undefined), + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => deleteProject(id), + onSuccess: () => { + showToast('success', 'Project deleted'); + invalidate(); + }, + onError: (error) => showToast('error', 'Delete failed', error instanceof Error ? error.message : undefined), + }); + + if (overviewQuery.isPending) { + return ; + } + + if (overviewQuery.isError) { + return ( +
+ +

Admin access required

+

+ This area is restricted to platform administrators. +

+
+ ); + } + + const overview = overviewQuery.data; + const stats = overview?.stats ?? {}; + const pending = overview?.pending_projects ?? []; + const users = usersQuery.data ?? []; + const allProjects = projectsQuery.data ?? []; + + return ( +
+
+

Admin console

+

+ Platform overview, project approvals, and user management. +

+
+ +
+ {Object.entries(statLabels).map(([key, label]) => ( +
+

{stats[key] ?? 0}

+

{label}

+
+ ))} +
+ +
+
+ +

Pending approvals

+ {pending.length > 0 ? ( + + {pending.length} + + ) : null} +
+ {pending.length > 0 ? ( +
+ {pending.map((project) => ( +
+ +
+ + {project.name} + +

{project.description || 'No description'}

+
+ +
+ ))} +
+ ) : ( +
+ Nothing waiting for approval. +
+ )} +
+ +
+
+ +

All projects

+
+
+ {allProjects.map((project) => ( +
+
+ + {project.name} + +

+ {project.stats?.service_count ?? 0} services · {project.stats?.deployment_count ?? 0} deploys +

+
+ + {project.isApproved ? 'public' : 'pending'} + + + + +
+ ))} + {allProjects.length === 0 ? ( +

No projects yet.

+ ) : null} +
+
+ +
+
+ +

Users

+
+
+ {users.map((user) => ( + adminMutation.mutate({ id: user.id, isAdmin })} + /> + ))} + {users.length === 0 ? ( +

No users.

+ ) : null} +
+
+ + {editingProject ? ( +
+
+
+

Edit project

+

Admin edit — changes apply immediately.

+
+
+
+ + setEditForm((f) => ({ ...f, name: e.target.value }))} + className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]" + /> +
+
+ +