diff --git a/app/backend/internal/api/auth.go b/app/backend/internal/api/auth.go index 1e51f61..8504f69 100644 --- a/app/backend/internal/api/auth.go +++ b/app/backend/internal/api/auth.go @@ -3,6 +3,8 @@ package api import ( "bytes" "containr/internal/database" + "containr/internal/database/sqlcdb" + "context" "database/sql" "encoding/json" "errors" @@ -63,11 +65,16 @@ func handleLogin(c *gin.Context) { // Find user by email var user User var hashedPassword string - err := db.QueryRow(` - SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), is_admin, created_at - FROM users - WHERE email = $1 - `, req.Email).Scan(&user.ID, &user.Email, &hashedPassword, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt) + urow, err := sqlcdb.New(db.DB).GetUserByEmailForAuth(context.Background(), req.Email) + user = User{ + ID: urow.ID.String(), + Email: urow.Email, + Name: urow.Name, + AvatarURL: urow.AvatarUrl, + IsAdmin: urow.IsAdmin, + CreatedAt: urow.CreatedAt.Time.String(), + } + hashedPassword = urow.PasswordHash if err == sql.ErrNoRows { authUser, ok := verifyBetterAuthCredentials(c, req.Email, req.Password) @@ -257,25 +264,30 @@ func createLocalUserFromBetterAuth(db *database.DB, authUser betterAuthLoginUser } // A mirrored first account owns the platform, same as handleRegister. - var total int - if err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&total); err != nil { + q := sqlcdb.New(db.DB) + total64, err := q.CountUsers(context.Background()) + if err != nil { return User{}, err } - var user User - err = db.QueryRow(` - INSERT INTO users (email, password_hash, name, avatar_url, is_admin) - VALUES ($1, $2, $3, NULLIF($4, ''), $5) - ON CONFLICT (email) DO UPDATE - SET name = EXCLUDED.name, - avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url), - updated_at = NOW() - RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at - `, email, string(hashedPassword), name, strings.TrimSpace(authUser.Image), total == 0). - Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt) + urow, err := q.UpsertLocalUser(context.Background(), sqlcdb.UpsertLocalUserParams{ + Email: email, + PasswordHash: string(hashedPassword), + Name: name, + Column4: strings.TrimSpace(authUser.Image), + IsAdmin: total64 == 0, + }) if err != nil { return User{}, err } + user := User{ + ID: urow.ID.String(), + Email: urow.Email, + Name: urow.Name, + AvatarURL: urow.AvatarUrl, + IsAdmin: urow.IsAdmin, + CreatedAt: urow.CreatedAt.Time.String(), + } return user, nil } @@ -302,8 +314,8 @@ func handleRegister(c *gin.Context) { return } - var existing int - err = db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", strings.ToLower(strings.TrimSpace(req.Email))).Scan(&existing) + q := sqlcdb.New(db.DB) + existing, err := q.CountUsersByEmail(context.Background(), strings.ToLower(strings.TrimSpace(req.Email))) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) return @@ -322,12 +334,20 @@ func handleRegister(c *gin.Context) { } // First account owns the platform. - var user User - err = db.QueryRow(` - INSERT INTO users (email, password_hash, name, is_admin) - VALUES ($1, $2, $3, $4) - RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at - `, req.Email, string(hashedPassword), req.Name, total == 0).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt) + urow, err := q.InsertUserAdmin(context.Background(), sqlcdb.InsertUserAdminParams{ + Email: req.Email, + PasswordHash: string(hashedPassword), + Name: req.Name, + IsAdmin: total == 0, + }) + user := User{ + ID: urow.ID.String(), + Email: urow.Email, + Name: urow.Name, + AvatarURL: urow.AvatarUrl, + IsAdmin: urow.IsAdmin, + CreatedAt: urow.CreatedAt.Time.String(), + } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"}) @@ -365,8 +385,8 @@ func handleCreateUser(c *gin.Context) { return } - var existing int - if err := db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", req.Email).Scan(&existing); err != nil { + existing, err := sqlcdb.New(db.DB).CountUsersByEmail(context.Background(), req.Email) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) return } @@ -390,12 +410,19 @@ func handleCreateUser(c *gin.Context) { return } - var user User - err = db.QueryRow(` - INSERT INTO users (email, password_hash, name) - VALUES ($1, $2, $3) - RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at - `, req.Email, string(hashedPassword), req.Name).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt) + urow, err := sqlcdb.New(db.DB).InsertUser(context.Background(), sqlcdb.InsertUserParams{ + Email: req.Email, + PasswordHash: string(hashedPassword), + Name: req.Name, + }) + user := User{ + ID: urow.ID.String(), + Email: urow.Email, + Name: urow.Name, + AvatarURL: urow.AvatarUrl, + IsAdmin: urow.IsAdmin, + CreatedAt: urow.CreatedAt.Time.String(), + } if err != nil { c.JSON(http.StatusConflict, gin.H{"error": "User already exists"}) return @@ -458,10 +485,11 @@ func createBetterAuthUser(c *gin.Context, name string, email string, password st } func countLocalUsers(db *database.DB) (int, error) { - var count int - if err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count); err != nil { + count64, err := sqlcdb.New(db.DB).CountUsers(context.Background()) + if err != nil { return 0, err } + count := int(count64) // Better Auth writes to auth_users; a row there only mirrors into users on // the first authenticated call. Count it too or a second public sign-up // slips through the bootstrap window. UNION by email avoids double-counting @@ -489,11 +517,15 @@ func handleGetProfile(c *gin.Context) { db := c.MustGet("db").(*database.DB) var user User - err := db.QueryRow(` - SELECT id, email, name, COALESCE(avatar_url, ''), is_admin, created_at - FROM users - WHERE id = $1 - `, userID).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt) + urow, err := sqlcdb.New(db.DB).GetUserByID(context.Background(), uuid.MustParse(userID)) + user = User{ + ID: urow.ID.String(), + Email: urow.Email, + Name: urow.Name, + AvatarURL: urow.AvatarUrl, + IsAdmin: urow.IsAdmin, + CreatedAt: urow.CreatedAt.Time.String(), + } if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) @@ -521,11 +553,11 @@ func handleUpdateProfile(c *gin.Context) { } // Update user profile - _, err := db.Exec(` - UPDATE users - SET name = COALESCE($1, name), avatar_url = COALESCE($2, avatar_url) - WHERE id = $3 - `, req.Name, req.AvatarURL, userID) + err := sqlcdb.New(db.DB).UpdateUserProfile(context.Background(), sqlcdb.UpdateUserProfileParams{ + Column1: req.Name, + Column2: req.AvatarURL, + ID: uuid.MustParse(userID), + }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update profile"}) diff --git a/app/backend/internal/api/security.go b/app/backend/internal/api/security.go index 765a17a..5901c61 100644 --- a/app/backend/internal/api/security.go +++ b/app/backend/internal/api/security.go @@ -2,7 +2,9 @@ package api import ( "containr/internal/database" + "containr/internal/database/sqlcdb" "containr/internal/security" + "context" "database/sql" "encoding/json" "fmt" @@ -33,8 +35,8 @@ func NewSecurityHandler(db *database.DB, encryptionKey string) *SecurityHandler // Notify the project owner when a background scan completes — criticals // earn a distinct title so they stand out in the notification list. scanner.OnScanComplete = func(scan *security.SecurityScan) { - var ownerID string - if err := db.QueryRow(`SELECT owner_id::text FROM projects WHERE id = $1`, scan.ProjectID).Scan(&ownerID); err != nil || ownerID == "" { + ownerID, err := sqlcdb.New(db.DB).GetProjectOwnerText(context.Background(), uuid.MustParse(scan.ProjectID)) + if err != nil || ownerID == "" { return } title := "Security scan completed" @@ -81,14 +83,10 @@ func (sh *SecurityHandler) StartSecurityScan(c *gin.Context) { return } - var serviceExists bool - err := sh.db.QueryRow( - `SELECT EXISTS( - SELECT 1 FROM services WHERE id = $1 AND project_id = $2 - )`, - req.ServiceID, - req.ProjectID, - ).Scan(&serviceExists) + serviceExists, err := sqlcdb.New(sh.db.DB).ServiceExistsInProject(context.Background(), sqlcdb.ServiceExistsInProjectParams{ + ID: uuid.MustParse(req.ServiceID), + ProjectID: uuid.MustParse(req.ProjectID), + }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate service"}) return @@ -164,40 +162,30 @@ func (sh *SecurityHandler) GetVulnerabilities(c *gin.Context) { } // Query vulnerabilities - rows, err := sh.db.Query(` - SELECT id, type, severity, title, description, service_id, status, found_at, resolved_at - FROM vulnerabilities - WHERE project_id = $1 - ORDER BY - CASE severity - WHEN 'critical' THEN 1 - WHEN 'high' THEN 2 - WHEN 'medium' THEN 3 - WHEN 'low' THEN 4 - END, - found_at DESC - `, projectID) - + rows, err := sqlcdb.New(sh.db.DB).ListVulnerabilities(context.Background(), uuid.MustParse(projectID)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get vulnerabilities"}) return } - defer rows.Close() var vulnerabilities []security.Vulnerability - for rows.Next() { - var vuln security.Vulnerability - var resolvedAt *time.Time - - err := rows.Scan(&vuln.ID, &vuln.Type, &vuln.Severity, &vuln.Title, &vuln.Description, - &vuln.ServiceID, &vuln.Status, &vuln.FoundAt, &resolvedAt) - - if err != nil { + for _, row := range rows { + // NULL description/service_id failed the original scan and dropped + // the row — keep the same filter. + if !row.Description.Valid || !row.ServiceID.Valid { continue } - - vuln.ResolvedAt = resolvedAt - vulnerabilities = append(vulnerabilities, vuln) + vulnerabilities = append(vulnerabilities, security.Vulnerability{ + ID: row.ID.String(), + Type: row.Type, + Severity: row.Severity, + Title: row.Title, + Description: row.Description.String, + ServiceID: row.ServiceID.UUID.String(), + Status: row.Status, + FoundAt: row.FoundAt, + ResolvedAt: timePtrNT(row.ResolvedAt), + }) } c.JSON(http.StatusOK, gin.H{"vulnerabilities": vulnerabilities}) @@ -227,11 +215,11 @@ func (sh *SecurityHandler) UpdateVulnerability(c *gin.Context) { resolvedAt = &now } - _, err := sh.db.Exec(` - UPDATE vulnerabilities - SET status = $1, resolved_at = $2 - WHERE id = $3 - `, req.Status, resolvedAt, vulnID) + err := sqlcdb.New(sh.db.DB).UpdateVulnerabilityStatus(context.Background(), sqlcdb.UpdateVulnerabilityStatusParams{ + Status: req.Status, + ResolvedAt: ntPtr(resolvedAt), + ID: uuid.MustParse(vulnID), + }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update vulnerability"}) @@ -271,13 +259,7 @@ func (sh *SecurityHandler) StartComplianceAssessment(c *gin.Context) { return } - var frameworkExists bool - err := sh.db.QueryRow( - `SELECT EXISTS( - SELECT 1 FROM compliance_frameworks WHERE id = $1 - )`, - req.FrameworkID, - ).Scan(&frameworkExists) + frameworkExists, err := sqlcdb.New(sh.db.DB).ComplianceFrameworkExists(context.Background(), uuid.MustParse(req.FrameworkID)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate framework"}) return @@ -321,30 +303,25 @@ func (sh *SecurityHandler) GetComplianceReport(c *gin.Context) { // GetComplianceFrameworks retrieves available compliance frameworks func (sh *SecurityHandler) GetComplianceFrameworks(c *gin.Context) { - rows, err := sh.db.Query(` - SELECT id, name, description, version, enabled, created_at - FROM compliance_frameworks - WHERE enabled = true - ORDER BY name - `) - + rows, err := sqlcdb.New(sh.db.DB).ListComplianceFrameworks(context.Background()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get compliance frameworks"}) return } - defer rows.Close() var frameworks []security.ComplianceFramework - for rows.Next() { - var framework security.ComplianceFramework - err := rows.Scan(&framework.ID, &framework.Name, &framework.Description, - &framework.Version, &framework.Enabled, &framework.CreatedAt) - - if err != nil { + for _, row := range rows { + if !row.Description.Valid { continue } - - frameworks = append(frameworks, framework) + frameworks = append(frameworks, security.ComplianceFramework{ + ID: row.ID.String(), + Name: row.Name, + Description: row.Description.String, + Version: row.Version, + Enabled: row.Enabled, + CreatedAt: row.CreatedAt, + }) } c.JSON(http.StatusOK, gin.H{"frameworks": frameworks}) @@ -385,23 +362,19 @@ func (sh *SecurityHandler) GetSecurityMetrics(c *gin.Context) { Resolved int `json:"resolved"` } - err := sh.db.QueryRow(` - SELECT - COUNT(*) as total, - COUNT(*) FILTER (WHERE severity = 'critical') as critical, - COUNT(*) FILTER (WHERE severity = 'high') as high, - COUNT(*) FILTER (WHERE severity = 'medium') as medium, - COUNT(*) FILTER (WHERE severity = 'low') as low, - COUNT(*) FILTER (WHERE status = 'open') as open, - COUNT(*) FILTER (WHERE status = 'resolved') as resolved - FROM vulnerabilities - WHERE project_id = $1 - `, projectID).Scan(&vulnMetrics.Total, &vulnMetrics.Critical, &vulnMetrics.High, - &vulnMetrics.Medium, &vulnMetrics.Low, &vulnMetrics.Open, &vulnMetrics.Resolved) + q := sqlcdb.New(sh.db.DB) + mrow, err := q.GetVulnerabilityMetrics(context.Background(), uuid.MustParse(projectID)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get vulnerability metrics"}) return } + vulnMetrics.Total = int(mrow.Total) + vulnMetrics.Critical = int(mrow.Critical) + vulnMetrics.High = int(mrow.High) + vulnMetrics.Medium = int(mrow.Medium) + vulnMetrics.Low = int(mrow.Low) + vulnMetrics.Open = int(mrow.Open) + vulnMetrics.Resolved = int(mrow.Resolved) // Get latest scan var latestScan struct { @@ -411,13 +384,11 @@ func (sh *SecurityHandler) GetSecurityMetrics(c *gin.Context) { Status string `json:"status"` } - err = sh.db.QueryRow(` - SELECT id, COALESCE((summary->>'score')::int, 0), started_at as scanned_at, status - FROM security_scans - WHERE project_id = $1 - ORDER BY started_at DESC - LIMIT 1 - `, projectID).Scan(&latestScan.ID, &latestScan.Score, &latestScan.ScannedAt, &latestScan.Status) + srow, err := q.GetLatestSecurityScan(context.Background(), uuid.MustParse(projectID)) + latestScan.ID = srow.ID.String() + latestScan.Score = int(srow.Score) + latestScan.ScannedAt = srow.ScannedAt + latestScan.Status = srow.Status if err == sql.ErrNoRows { latestScan = struct { @@ -438,13 +409,10 @@ func (sh *SecurityHandler) GetSecurityMetrics(c *gin.Context) { LastAssessed *time.Time `json:"last_assessed"` } - err = sh.db.QueryRow(` - SELECT overall_status, score, assessment_date - FROM compliance_reports - WHERE project_id = $1 - ORDER BY assessment_date DESC - LIMIT 1 - `, projectID).Scan(&complianceStatus.OverallStatus, &complianceStatus.Score, &complianceStatus.LastAssessed) + crow, err := q.GetLatestComplianceReport(context.Background(), uuid.MustParse(projectID)) + complianceStatus.OverallStatus = crow.OverallStatus + complianceStatus.Score = int(crow.Score) + complianceStatus.LastAssessed = &crow.AssessmentDate if err == sql.ErrNoRows { complianceStatus = struct { @@ -614,21 +582,11 @@ func (sh *SecurityHandler) requireProjectAccess(c *gin.Context, projectID string return "", false } - var hasAccess bool - err := sh.db.QueryRow( - `SELECT EXISTS ( - SELECT 1 - FROM projects p - WHERE p.id = $1 - 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, optionalUserUUID(c), contextIsAdmin(c), - ).Scan(&hasAccess) + hasAccess, err := sqlcdb.New(sh.db.DB).CheckSecurityProjectAccess(context.Background(), sqlcdb.CheckSecurityProjectAccessParams{ + ID: uuid.MustParse(projectID), + OwnerID: optionalUserUUID(c), + Column3: contextIsAdmin(c), + }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify project access"}) return "", false @@ -649,7 +607,8 @@ func (sh *SecurityHandler) requireSecurityScanAccess(c *gin.Context, scanID stri } var projectID string - err := sh.db.QueryRow("SELECT project_id FROM security_scans WHERE id = $1", scanID).Scan(&projectID) + pid, err := sqlcdb.New(sh.db.DB).GetSecurityScanProjectID(context.Background(), uuid.MustParse(scanID)) + projectID = pid.String() if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "Security scan not found"}) return false @@ -670,7 +629,8 @@ func (sh *SecurityHandler) requireComplianceReportAccess(c *gin.Context, reportI } var projectID string - err := sh.db.QueryRow("SELECT project_id FROM compliance_reports WHERE id = $1", reportID).Scan(&projectID) + pid, err := sqlcdb.New(sh.db.DB).GetComplianceReportProjectID(context.Background(), uuid.MustParse(reportID)) + projectID = pid.String() if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "Compliance report not found"}) return false @@ -691,7 +651,8 @@ func (sh *SecurityHandler) requireVulnerabilityAccess(c *gin.Context, vulnID str } var projectID string - err := sh.db.QueryRow("SELECT project_id FROM vulnerabilities WHERE id = $1", vulnID).Scan(&projectID) + pid, err := sqlcdb.New(sh.db.DB).GetVulnerabilityProjectID(context.Background(), uuid.MustParse(vulnID)) + projectID = pid.String() if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "Vulnerability not found"}) return "", false diff --git a/app/backend/internal/api/security_test.go b/app/backend/internal/api/security_test.go new file mode 100644 index 0000000..5ccb55e --- /dev/null +++ b/app/backend/internal/api/security_test.go @@ -0,0 +1,212 @@ +package api + +import ( + "context" + "database/sql" + "strings" + "testing" + "time" + + "containr/internal/database" + "containr/internal/database/sqlcdb" + + "github.com/google/uuid" +) + +// Live invariant test against real Postgres — set METRICS_TEST_DSN (or +// DATABASE_URL). Covers the generated auth/security queries: user upsert +// conflict path, project-access EXISTS predicate, vulnerability ordering and +// status update, latest-scan/report lookups, and the framework list. +func TestSecurityLiveInvariants(t *testing.T) { + dsn := testDSNString(t) + db, err := database.NewConnection(dsn) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer db.Close() + ctx := context.Background() + q := sqlcdb.New(db.DB) + + userID := uuid.New() + otherID := uuid.New() + projectID := uuid.New() + envID := uuid.New() + serviceID := uuid.New() + scanID := uuid.New() + reportID := uuid.New() + frameworkID := uuid.New() + vulnCrit := uuid.New() + vulnLow := uuid.New() + suffix := strings.ReplaceAll(userID.String()[:8], "-", "") + must := func(err error) { + t.Helper() + if err != nil { + t.Fatalf("setup: %v", err) + } + } + _, err = db.Exec(`INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, 'x', 'test'), ($3, $4, 'x', 'other')`, + userID, "sec-"+suffix+"@test.local", otherID, "sec-"+suffix+"-o@test.local") + must(err) + _, err = db.Exec(`INSERT INTO projects (id, name, owner_id, is_approved) VALUES ($1, $2, $3, false)`, + projectID, "sec-"+suffix, userID) + must(err) + _, err = db.Exec(`INSERT INTO environments (id, name, project_id) VALUES ($1, 'production', $2)`, + envID, projectID) + must(err) + _, err = db.Exec(`INSERT INTO services (id, name, project_id, environment_id, service_type, source_type) + VALUES ($1, $2, $3, $4, 'web', 'image')`, + serviceID, "sec-"+suffix, projectID, envID) + must(err) + defer func() { _, _ = db.Exec(`DELETE FROM users WHERE id IN ($1, $2)`, userID, otherID) }() + + // Auth queries: upsert conflicts update rather than error, counts see rows. + _, err = q.InsertUserAdmin(ctx, sqlcdb.InsertUserAdminParams{ + Email: "ins-" + suffix + "@test.local", PasswordHash: "x", Name: "ins", IsAdmin: false, + }) + must(err) + defer func() { _, _ = db.Exec(`DELETE FROM users WHERE email = $1`, "ins-"+suffix+"@test.local") }() + urow, err := q.UpsertLocalUser(ctx, sqlcdb.UpsertLocalUserParams{ + Email: "ins-" + suffix + "@test.local", PasswordHash: "y", Name: "renamed", + Column4: "http://img", IsAdmin: false, + }) + must(err) + if urow.Name != "renamed" || urow.AvatarUrl != "http://img" { + t.Fatalf("upsert: %+v", urow) + } + byEmail, err := q.GetUserByEmailForAuth(ctx, "ins-"+suffix+"@test.local") + must(err) + if byEmail.ID != urow.ID || byEmail.PasswordHash != "x" { + t.Fatalf("by email: %+v", byEmail) + } + count, err := q.CountUsersByEmail(ctx, "ins-"+suffix+"@test.local") + must(err) + if count != 1 { + t.Fatalf("count by email: %d", count) + } + byID, err := q.GetUserByID(ctx, urow.ID) + must(err) + if byID.Email != "ins-"+suffix+"@test.local" { + t.Fatalf("by id: %+v", byID) + } + + // Access predicate: unapproved project is owner/admin/member only. + ownerAccess, err := q.CheckSecurityProjectAccess(ctx, sqlcdb.CheckSecurityProjectAccessParams{ + ID: projectID, OwnerID: userID, Column3: false, + }) + must(err) + if !ownerAccess { + t.Fatal("owner should have access") + } + strangerAccess, err := q.CheckSecurityProjectAccess(ctx, sqlcdb.CheckSecurityProjectAccessParams{ + ID: projectID, OwnerID: otherID, Column3: false, + }) + must(err) + if strangerAccess { + t.Fatal("stranger should not have access") + } + adminAccess, err := q.CheckSecurityProjectAccess(ctx, sqlcdb.CheckSecurityProjectAccessParams{ + ID: projectID, OwnerID: otherID, Column3: true, + }) + must(err) + if !adminAccess { + t.Fatal("admin should have access") + } + ownerText, err := q.GetProjectOwnerText(ctx, projectID) + must(err) + if ownerText != userID.String() { + t.Fatalf("owner text: %q", ownerText) + } + exists, err := q.ServiceExistsInProject(ctx, sqlcdb.ServiceExistsInProjectParams{ + ID: serviceID, ProjectID: projectID, + }) + must(err) + if !exists { + t.Fatal("service should exist in project") + } + + // Scan/report/vuln rows via raw inserts (fixtures), reads via sqlc. + _, err = db.Exec(`INSERT INTO security_scans (id, project_id, service_id, scan_type, status, summary, started_at) + VALUES ($1, $2, $3, 'dependency', 'completed', '{"score": 85}', NOW() - interval '1 hour')`, + scanID, projectID, serviceID) + must(err) + _, err = db.Exec(`INSERT INTO compliance_frameworks (id, name, description, version, enabled) + VALUES ($1, $2, 'desc', '1.0', true)`, + frameworkID, "fw-"+suffix) + must(err) + defer func() { _, _ = db.Exec(`DELETE FROM compliance_frameworks WHERE id = $1`, frameworkID) }() + _, err = db.Exec(`INSERT INTO compliance_reports (id, project_id, framework_id, overall_status, score, assessment_date) + VALUES ($1, $2, $3, 'compliant', 92, NOW() - interval '30 minutes')`, + reportID, projectID, frameworkID) + must(err) + defer func() { _, _ = db.Exec(`DELETE FROM compliance_reports WHERE id = $1`, reportID) }() + _, err = db.Exec(`INSERT INTO vulnerabilities (id, type, severity, title, description, service_id, project_id, status, found_at) + VALUES ($1, 'dependency', 'low', 'low-v', 'd', $3, $4, 'open', NOW() - interval '2 hours'), + ($2, 'dependency', 'critical', 'crit-v', 'd', $3, $4, 'open', NOW() - interval '1 hour')`, + vulnLow, vulnCrit, serviceID, projectID) + must(err) + defer func() { _, _ = db.Exec(`DELETE FROM vulnerabilities WHERE project_id = $1`, projectID) }() + defer func() { _, _ = db.Exec(`DELETE FROM security_scans WHERE id = $1`, scanID) }() + + // Severity ordering: critical first. + vulns, err := q.ListVulnerabilities(ctx, projectID) + must(err) + if len(vulns) != 2 || vulns[0].ID != vulnCrit { + t.Fatalf("vuln ordering: %+v", vulns) + } + metrics, err := q.GetVulnerabilityMetrics(ctx, projectID) + must(err) + if metrics.Total != 2 || metrics.Critical != 1 || metrics.Low != 1 || metrics.Open != 2 { + t.Fatalf("metrics: %+v", metrics) + } + must(q.UpdateVulnerabilityStatus(ctx, sqlcdb.UpdateVulnerabilityStatusParams{ + Status: "resolved", + ResolvedAt: sql.NullTime{Time: time.Now(), Valid: true}, + ID: vulnCrit, + })) + metrics, err = q.GetVulnerabilityMetrics(ctx, projectID) + must(err) + if metrics.Open != 1 || metrics.Resolved != 1 { + t.Fatalf("metrics after resolve: %+v", metrics) + } + + // Latest scan/report lookups. + scan, err := q.GetLatestSecurityScan(ctx, projectID) + must(err) + if scan.ID != scanID || scan.Score != 85 || scan.Status != "completed" { + t.Fatalf("latest scan: %+v", scan) + } + report, err := q.GetLatestComplianceReport(ctx, projectID) + must(err) + if report.OverallStatus != "compliant" || report.Score != 92 { + t.Fatalf("latest report: %+v", report) + } + + // Project-ID lookups used by the access guards. + scanPID, err := q.GetSecurityScanProjectID(ctx, scanID) + must(err) + reportPID, err := q.GetComplianceReportProjectID(ctx, reportID) + must(err) + vulnPID, err := q.GetVulnerabilityProjectID(ctx, vulnCrit) + must(err) + if scanPID != projectID || reportPID != projectID || vulnPID != projectID { + t.Fatalf("project id lookups: %v %v %v", scanPID, reportPID, vulnPID) + } + + // Framework list only returns enabled rows. + frameworks, err := q.ListComplianceFrameworks(ctx) + must(err) + found := false + for _, f := range frameworks { + if f.ID == frameworkID && f.Enabled { + found = true + } + } + if !found { + t.Fatal("enabled framework missing from list") + } + frameworkExists, err := q.ComplianceFrameworkExists(ctx, frameworkID) + must(err) + if !frameworkExists { + t.Fatal("framework should exist") + } +} diff --git a/app/backend/internal/database/sqlcdb/auth.sql.go b/app/backend/internal/database/sqlcdb/auth.sql.go new file mode 100644 index 0000000..4bc081c --- /dev/null +++ b/app/backend/internal/database/sqlcdb/auth.sql.go @@ -0,0 +1,233 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: auth.sql + +package sqlcdb + +import ( + "context" + "database/sql" + + "github.com/google/uuid" +) + +const countUsers = `-- name: CountUsers :one +SELECT COUNT(*) FROM users +` + +func (q *Queries) CountUsers(ctx context.Context) (int64, error) { + row := q.db.QueryRowContext(ctx, countUsers) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countUsersByEmail = `-- name: CountUsersByEmail :one +SELECT COUNT(*) FROM users WHERE email = $1 +` + +func (q *Queries) CountUsersByEmail(ctx context.Context, email string) (int64, error) { + row := q.db.QueryRowContext(ctx, countUsersByEmail, email) + var count int64 + err := row.Scan(&count) + return count, err +} + +const getUserByEmailForAuth = `-- name: GetUserByEmailForAuth :one +SELECT id, email, password_hash, name, COALESCE(avatar_url, '') AS avatar_url, is_admin, created_at +FROM users WHERE email = $1 +` + +type GetUserByEmailForAuthRow struct { + ID uuid.UUID `json:"id"` + Email string `json:"email"` + PasswordHash string `json:"password_hash"` + Name string `json:"name"` + AvatarUrl string `json:"avatar_url"` + IsAdmin bool `json:"is_admin"` + CreatedAt sql.NullTime `json:"created_at"` +} + +func (q *Queries) GetUserByEmailForAuth(ctx context.Context, email string) (GetUserByEmailForAuthRow, error) { + row := q.db.QueryRowContext(ctx, getUserByEmailForAuth, email) + var i GetUserByEmailForAuthRow + err := row.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.Name, + &i.AvatarUrl, + &i.IsAdmin, + &i.CreatedAt, + ) + return i, err +} + +const getUserByID = `-- name: GetUserByID :one +SELECT id, email, name, COALESCE(avatar_url, '') AS avatar_url, is_admin, created_at +FROM users WHERE id = $1 +` + +type GetUserByIDRow struct { + ID uuid.UUID `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + AvatarUrl string `json:"avatar_url"` + IsAdmin bool `json:"is_admin"` + CreatedAt sql.NullTime `json:"created_at"` +} + +func (q *Queries) GetUserByID(ctx context.Context, id uuid.UUID) (GetUserByIDRow, error) { + row := q.db.QueryRowContext(ctx, getUserByID, id) + var i GetUserByIDRow + err := row.Scan( + &i.ID, + &i.Email, + &i.Name, + &i.AvatarUrl, + &i.IsAdmin, + &i.CreatedAt, + ) + return i, err +} + +const insertUser = `-- name: InsertUser :one +INSERT INTO users (email, password_hash, name) +VALUES ($1, $2, $3) +RETURNING id, email, name, COALESCE(avatar_url, '') AS avatar_url, is_admin, created_at +` + +type InsertUserParams struct { + Email string `json:"email"` + PasswordHash string `json:"password_hash"` + Name string `json:"name"` +} + +type InsertUserRow struct { + ID uuid.UUID `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + AvatarUrl string `json:"avatar_url"` + IsAdmin bool `json:"is_admin"` + CreatedAt sql.NullTime `json:"created_at"` +} + +func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (InsertUserRow, error) { + row := q.db.QueryRowContext(ctx, insertUser, arg.Email, arg.PasswordHash, arg.Name) + var i InsertUserRow + err := row.Scan( + &i.ID, + &i.Email, + &i.Name, + &i.AvatarUrl, + &i.IsAdmin, + &i.CreatedAt, + ) + return i, err +} + +const insertUserAdmin = `-- name: InsertUserAdmin :one +INSERT INTO users (email, password_hash, name, is_admin) +VALUES ($1, $2, $3, $4) +RETURNING id, email, name, COALESCE(avatar_url, '') AS avatar_url, is_admin, created_at +` + +type InsertUserAdminParams struct { + Email string `json:"email"` + PasswordHash string `json:"password_hash"` + Name string `json:"name"` + IsAdmin bool `json:"is_admin"` +} + +type InsertUserAdminRow struct { + ID uuid.UUID `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + AvatarUrl string `json:"avatar_url"` + IsAdmin bool `json:"is_admin"` + CreatedAt sql.NullTime `json:"created_at"` +} + +func (q *Queries) InsertUserAdmin(ctx context.Context, arg InsertUserAdminParams) (InsertUserAdminRow, error) { + row := q.db.QueryRowContext(ctx, insertUserAdmin, + arg.Email, + arg.PasswordHash, + arg.Name, + arg.IsAdmin, + ) + var i InsertUserAdminRow + err := row.Scan( + &i.ID, + &i.Email, + &i.Name, + &i.AvatarUrl, + &i.IsAdmin, + &i.CreatedAt, + ) + return i, err +} + +const updateUserProfile = `-- name: UpdateUserProfile :exec +UPDATE users +SET name = COALESCE($1::varchar, name), avatar_url = COALESCE($2::varchar, avatar_url) +WHERE id = $3 +` + +type UpdateUserProfileParams struct { + Column1 string `json:"column_1"` + Column2 string `json:"column_2"` + ID uuid.UUID `json:"id"` +} + +func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error { + _, err := q.db.ExecContext(ctx, updateUserProfile, arg.Column1, arg.Column2, arg.ID) + return err +} + +const upsertLocalUser = `-- name: UpsertLocalUser :one +INSERT INTO users (email, password_hash, name, avatar_url, is_admin) +VALUES ($1, $2, $3, NULLIF($4::varchar, ''), $5) +ON CONFLICT (email) DO UPDATE +SET name = EXCLUDED.name, + avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url), + updated_at = NOW() +RETURNING id, email, name, COALESCE(avatar_url, '') AS avatar_url, is_admin, created_at +` + +type UpsertLocalUserParams struct { + Email string `json:"email"` + PasswordHash string `json:"password_hash"` + Name string `json:"name"` + Column4 string `json:"column_4"` + IsAdmin bool `json:"is_admin"` +} + +type UpsertLocalUserRow struct { + ID uuid.UUID `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + AvatarUrl string `json:"avatar_url"` + IsAdmin bool `json:"is_admin"` + CreatedAt sql.NullTime `json:"created_at"` +} + +func (q *Queries) UpsertLocalUser(ctx context.Context, arg UpsertLocalUserParams) (UpsertLocalUserRow, error) { + row := q.db.QueryRowContext(ctx, upsertLocalUser, + arg.Email, + arg.PasswordHash, + arg.Name, + arg.Column4, + arg.IsAdmin, + ) + var i UpsertLocalUserRow + err := row.Scan( + &i.ID, + &i.Email, + &i.Name, + &i.AvatarUrl, + &i.IsAdmin, + &i.CreatedAt, + ) + return i, err +} diff --git a/app/backend/internal/database/sqlcdb/models.go b/app/backend/internal/database/sqlcdb/models.go index 4d4c0f6..f1b8518 100644 --- a/app/backend/internal/database/sqlcdb/models.go +++ b/app/backend/internal/database/sqlcdb/models.go @@ -78,6 +78,28 @@ type Banner struct { UpdatedAt sql.NullTime `json:"updated_at"` } +type ComplianceFramework struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description sql.NullString `json:"description"` + Version string `json:"version"` + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ComplianceReport struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + FrameworkID uuid.UUID `json:"framework_id"` + AssessmentDate time.Time `json:"assessment_date"` + Assessor sql.NullString `json:"assessor"` + OverallStatus string `json:"overall_status"` + Score int32 `json:"score"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + type ContainerInstance struct { ID string `json:"id"` Name string `json:"name"` @@ -397,6 +419,19 @@ type Registry struct { UpdatedAt time.Time `json:"updated_at"` } +type SecurityScan struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + ServiceID uuid.NullUUID `json:"service_id"` + ScanType string `json:"scan_type"` + Status string `json:"status"` + StartedAt time.Time `json:"started_at"` + CompletedAt sql.NullTime `json:"completed_at"` + Summary pqtype.NullRawMessage `json:"summary"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + type Service struct { ID uuid.UUID `json:"id"` Name string `json:"name"` @@ -500,6 +535,17 @@ type ServiceTemplate struct { UpdatedAt sql.NullTime `json:"updated_at"` } +type User struct { + ID uuid.UUID `json:"id"` + Email string `json:"email"` + PasswordHash string `json:"password_hash"` + Name string `json:"name"` + AvatarUrl sql.NullString `json:"avatar_url"` + CreatedAt sql.NullTime `json:"created_at"` + UpdatedAt sql.NullTime `json:"updated_at"` + IsAdmin bool `json:"is_admin"` +} + type UserInvite struct { ID uuid.UUID `json:"id"` TokenHash string `json:"token_hash"` @@ -524,6 +570,21 @@ type UserToken struct { CreatedAt sql.NullTime `json:"created_at"` } +type Vulnerability struct { + ID uuid.UUID `json:"id"` + Type string `json:"type"` + Severity string `json:"severity"` + Title string `json:"title"` + Description sql.NullString `json:"description"` + ServiceID uuid.NullUUID `json:"service_id"` + ProjectID uuid.UUID `json:"project_id"` + Status string `json:"status"` + FoundAt time.Time `json:"found_at"` + ResolvedAt sql.NullTime `json:"resolved_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + type WebhookDelivery struct { ID uuid.UUID `json:"id"` WebhookID uuid.UUID `json:"webhook_id"` diff --git a/app/backend/internal/database/sqlcdb/querier.go b/app/backend/internal/database/sqlcdb/querier.go index 988ad2b..d5810a0 100644 --- a/app/backend/internal/database/sqlcdb/querier.go +++ b/app/backend/internal/database/sqlcdb/querier.go @@ -16,6 +16,7 @@ type Querier interface { // Portable bucket bucketing (no TimescaleDB): floor epoch seconds into // bucket-sized buckets, then back to timestamptz. AggregateNodeMetrics(ctx context.Context, arg AggregateNodeMetricsParams) ([]AggregateNodeMetricsRow, error) + CheckSecurityProjectAccess(ctx context.Context, arg CheckSecurityProjectAccessParams) (bool, error) ClearServiceDomainDefault(ctx context.Context, serviceID uuid.UUID) error ClearServiceNodePins(ctx context.Context, nodeID sql.NullString) (int64, error) CloneServiceDomains(ctx context.Context, arg CloneServiceDomainsParams) error @@ -23,6 +24,7 @@ type Querier interface { CloneServiceVariables(ctx context.Context, arg CloneServiceVariablesParams) error CompleteCommand(ctx context.Context, arg CompleteCommandParams) (AgentCommand, error) CompleteDeployment(ctx context.Context, arg CompleteDeploymentParams) error + ComplianceFrameworkExists(ctx context.Context, id uuid.UUID) (bool, error) CountAccessibleDeployments(ctx context.Context, arg CountAccessibleDeploymentsParams) (int64, error) CountActivePreviewsForBranch(ctx context.Context, arg CountActivePreviewsForBranchParams) (int64, error) CountDatabaseServicesByUserAndName(ctx context.Context, arg CountDatabaseServicesByUserAndNameParams) (int64, error) @@ -36,6 +38,8 @@ type Querier interface { CountServicesByProjectAndName(ctx context.Context, arg CountServicesByProjectAndNameParams) (int64, error) CountUnreadNotificationsByUser(ctx context.Context, userID uuid.UUID) (int64, error) CountUserRepositories(ctx context.Context, userID uuid.UUID) (int64, error) + CountUsers(ctx context.Context) (int64, error) + CountUsersByEmail(ctx context.Context, email string) (int64, error) CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeAgent, error) CreateAgentAuthToken(ctx context.Context, arg CreateAgentAuthTokenParams) (AgentAuthToken, error) CreateBackupTarget(ctx context.Context, arg CreateBackupTargetParams) error @@ -84,6 +88,7 @@ type Querier interface { GetBackupTargetByIDAndUser(ctx context.Context, arg GetBackupTargetByIDAndUserParams) (BackupTarget, error) GetBannerByID(ctx context.Context, id uuid.UUID) (Banner, error) GetCommandForAgent(ctx context.Context, arg GetCommandForAgentParams) (AgentCommand, error) + GetComplianceReportProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) GetContainer(ctx context.Context, id string) (ContainerInstance, error) GetContainerForAgent(ctx context.Context, arg GetContainerForAgentParams) (ContainerInstance, error) GetCronJob(ctx context.Context, id uuid.UUID) (CronJob, error) @@ -109,6 +114,8 @@ type Querier interface { GetGitWebhookForPush(ctx context.Context, id uuid.UUID) (GetGitWebhookForPushRow, error) GetLastAgentCommandByType(ctx context.Context, arg GetLastAgentCommandByTypeParams) (AgentCommand, error) GetLastDeployedImage(ctx context.Context, serviceID uuid.UUID) (string, error) + GetLatestComplianceReport(ctx context.Context, projectID uuid.UUID) (GetLatestComplianceReportRow, error) + GetLatestSecurityScan(ctx context.Context, projectID uuid.UUID) (GetLatestSecurityScanRow, error) GetOutboundWebhookByID(ctx context.Context, id uuid.UUID) (OutboundWebhook, error) GetOutboundWebhookByIDAndUser(ctx context.Context, arg GetOutboundWebhookByIDAndUserParams) (OutboundWebhook, error) GetPreviewEnvironment(ctx context.Context, arg GetPreviewEnvironmentParams) (GetPreviewEnvironmentRow, error) @@ -122,7 +129,9 @@ type Querier interface { GetProjectOwner(ctx context.Context, id uuid.UUID) (uuid.UUID, error) GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID) (uuid.UUID, error) GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) + GetProjectOwnerText(ctx context.Context, id uuid.UUID) (string, error) GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error) + GetSecurityScanProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) GetServiceAccess(ctx context.Context, id uuid.UUID) (GetServiceAccessRow, error) GetServiceDomainDefault(ctx context.Context, serviceID uuid.UUID) (string, error) GetServiceDomainFirst(ctx context.Context, serviceID uuid.UUID) (string, error) @@ -138,8 +147,12 @@ type Querier interface { GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, error) GetServiceTypeBrief(ctx context.Context, arg GetServiceTypeBriefParams) (GetServiceTypeBriefRow, error) GetServiceVariableValue(ctx context.Context, arg GetServiceVariableValueParams) (string, error) + GetUserByEmailForAuth(ctx context.Context, email string) (GetUserByEmailForAuthRow, error) + GetUserByID(ctx context.Context, id uuid.UUID) (GetUserByIDRow, error) GetUserInviteByTokenHash(ctx context.Context, tokenHash string) (UserInvite, error) GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error) + GetVulnerabilityMetrics(ctx context.Context, projectID uuid.UUID) (GetVulnerabilityMetricsRow, error) + GetVulnerabilityProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error InsertCronExecution(ctx context.Context, arg InsertCronExecutionParams) error InsertCronJob(ctx context.Context, arg InsertCronJobParams) error @@ -149,6 +162,8 @@ type Querier interface { InsertNotificationChannel(ctx context.Context, arg InsertNotificationChannelParams) (NotificationChannel, error) InsertPendingDeployment(ctx context.Context, arg InsertPendingDeploymentParams) error InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error + InsertUser(ctx context.Context, arg InsertUserParams) (InsertUserRow, error) + InsertUserAdmin(ctx context.Context, arg InsertUserAdminParams) (InsertUserAdminRow, error) ListActiveBanners(ctx context.Context) ([]Banner, error) ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error) ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error) @@ -158,6 +173,7 @@ type Querier interface { ListBackupTargetsByUser(ctx context.Context, userID string) ([]BackupTarget, error) ListBanners(ctx context.Context) ([]Banner, error) ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error) + ListComplianceFrameworks(ctx context.Context) ([]ListComplianceFrameworksRow, error) ListConnectedRepositories(ctx context.Context, arg ListConnectedRepositoriesParams) ([]ListConnectedRepositoriesRow, error) ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error) ListCronExecutions(ctx context.Context, cronJobID uuid.UUID) ([]CronExecution, error) @@ -207,6 +223,7 @@ type Querier interface { ListSweepablePreviews(ctx context.Context) ([]ListSweepablePreviewsRow, error) ListUserInvites(ctx context.Context) ([]UserInvite, error) ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, error) + ListVulnerabilities(ctx context.Context, projectID uuid.UUID) ([]ListVulnerabilitiesRow, error) ListWebhookDeliveriesByWebhook(ctx context.Context, webhookID uuid.UUID) ([]WebhookDelivery, error) MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error @@ -222,6 +239,7 @@ type Querier interface { RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error) RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error) ScrubCommandPayload(ctx context.Context, arg ScrubCommandPayloadParams) error + ServiceExistsInProject(ctx context.Context, arg ServiceExistsInProjectParams) (bool, error) ServiceWriteAccess(ctx context.Context, arg ServiceWriteAccessParams) (bool, error) SetAgentDefaultDomain(ctx context.Context, arg SetAgentDefaultDomainParams) error SetAgentSchedulable(ctx context.Context, arg SetAgentSchedulableParams) error @@ -268,13 +286,16 @@ type Querier interface { UpdatePreviewEnvironment(ctx context.Context, arg UpdatePreviewEnvironmentParams) error UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (int64, error) UpdateServicePreviewBranch(ctx context.Context, arg UpdateServicePreviewBranchParams) error + UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplateParams) (int64, error) + UpdateVulnerabilityStatus(ctx context.Context, arg UpdateVulnerabilityStatusParams) error UpdateWebhookDeliveryResult(ctx context.Context, arg UpdateWebhookDeliveryResultParams) error UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvironmentVariableParams) error UpsertGitHubAppProvider(ctx context.Context, arg UpsertGitHubAppProviderParams) (UpsertGitHubAppProviderRow, error) UpsertGitRepository(ctx context.Context, arg UpsertGitRepositoryParams) error UpsertGitWebhook(ctx context.Context, arg UpsertGitWebhookParams) (UpsertGitWebhookRow, error) UpsertInstanceMetrics(ctx context.Context, arg UpsertInstanceMetricsParams) error + UpsertLocalUser(ctx context.Context, arg UpsertLocalUserParams) (UpsertLocalUserRow, error) UpsertNodeMetrics(ctx context.Context, arg UpsertNodeMetricsParams) error UpsertServiceContainer(ctx context.Context, arg UpsertServiceContainerParams) error UpsertServiceMetrics(ctx context.Context, arg UpsertServiceMetricsParams) error diff --git a/app/backend/internal/database/sqlcdb/security.sql.go b/app/backend/internal/database/sqlcdb/security.sql.go new file mode 100644 index 0000000..89e2b14 --- /dev/null +++ b/app/backend/internal/database/sqlcdb/security.sql.go @@ -0,0 +1,318 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: security.sql + +package sqlcdb + +import ( + "context" + "database/sql" + "time" + + "github.com/google/uuid" +) + +const checkSecurityProjectAccess = `-- name: CheckSecurityProjectAccess :one +SELECT EXISTS ( + SELECT 1 + FROM projects p + WHERE p.id = $1 + 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 + )) +) +` + +type CheckSecurityProjectAccessParams struct { + ID uuid.UUID `json:"id"` + OwnerID uuid.UUID `json:"owner_id"` + Column3 bool `json:"column_3"` +} + +func (q *Queries) CheckSecurityProjectAccess(ctx context.Context, arg CheckSecurityProjectAccessParams) (bool, error) { + row := q.db.QueryRowContext(ctx, checkSecurityProjectAccess, arg.ID, arg.OwnerID, arg.Column3) + var exists bool + err := row.Scan(&exists) + return exists, err +} + +const complianceFrameworkExists = `-- name: ComplianceFrameworkExists :one +SELECT EXISTS(SELECT 1 FROM compliance_frameworks WHERE id = $1) +` + +func (q *Queries) ComplianceFrameworkExists(ctx context.Context, id uuid.UUID) (bool, error) { + row := q.db.QueryRowContext(ctx, complianceFrameworkExists, id) + var exists bool + err := row.Scan(&exists) + return exists, err +} + +const getComplianceReportProjectID = `-- name: GetComplianceReportProjectID :one +SELECT project_id FROM compliance_reports WHERE id = $1 +` + +func (q *Queries) GetComplianceReportProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) { + row := q.db.QueryRowContext(ctx, getComplianceReportProjectID, id) + var project_id uuid.UUID + err := row.Scan(&project_id) + return project_id, err +} + +const getLatestComplianceReport = `-- name: GetLatestComplianceReport :one +SELECT overall_status, score, assessment_date +FROM compliance_reports +WHERE project_id = $1 +ORDER BY assessment_date DESC +LIMIT 1 +` + +type GetLatestComplianceReportRow struct { + OverallStatus string `json:"overall_status"` + Score int32 `json:"score"` + AssessmentDate time.Time `json:"assessment_date"` +} + +func (q *Queries) GetLatestComplianceReport(ctx context.Context, projectID uuid.UUID) (GetLatestComplianceReportRow, error) { + row := q.db.QueryRowContext(ctx, getLatestComplianceReport, projectID) + var i GetLatestComplianceReportRow + err := row.Scan(&i.OverallStatus, &i.Score, &i.AssessmentDate) + return i, err +} + +const getLatestSecurityScan = `-- name: GetLatestSecurityScan :one +SELECT id, COALESCE((summary->>'score')::int, 0)::int AS score, started_at AS scanned_at, status +FROM security_scans +WHERE project_id = $1 +ORDER BY started_at DESC +LIMIT 1 +` + +type GetLatestSecurityScanRow struct { + ID uuid.UUID `json:"id"` + Score int32 `json:"score"` + ScannedAt time.Time `json:"scanned_at"` + Status string `json:"status"` +} + +func (q *Queries) GetLatestSecurityScan(ctx context.Context, projectID uuid.UUID) (GetLatestSecurityScanRow, error) { + row := q.db.QueryRowContext(ctx, getLatestSecurityScan, projectID) + var i GetLatestSecurityScanRow + err := row.Scan( + &i.ID, + &i.Score, + &i.ScannedAt, + &i.Status, + ) + return i, err +} + +const getProjectOwnerText = `-- name: GetProjectOwnerText :one +SELECT owner_id::text FROM projects WHERE id = $1 +` + +func (q *Queries) GetProjectOwnerText(ctx context.Context, id uuid.UUID) (string, error) { + row := q.db.QueryRowContext(ctx, getProjectOwnerText, id) + var owner_id string + err := row.Scan(&owner_id) + return owner_id, err +} + +const getSecurityScanProjectID = `-- name: GetSecurityScanProjectID :one +SELECT project_id FROM security_scans WHERE id = $1 +` + +func (q *Queries) GetSecurityScanProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) { + row := q.db.QueryRowContext(ctx, getSecurityScanProjectID, id) + var project_id uuid.UUID + err := row.Scan(&project_id) + return project_id, err +} + +const getVulnerabilityMetrics = `-- name: GetVulnerabilityMetrics :one +SELECT + COUNT(*) AS total, + COUNT(*) FILTER (WHERE severity = 'critical') AS critical, + COUNT(*) FILTER (WHERE severity = 'high') AS high, + COUNT(*) FILTER (WHERE severity = 'medium') AS medium, + COUNT(*) FILTER (WHERE severity = 'low') AS low, + COUNT(*) FILTER (WHERE status = 'open') AS open, + COUNT(*) FILTER (WHERE status = 'resolved') AS resolved +FROM vulnerabilities +WHERE project_id = $1 +` + +type GetVulnerabilityMetricsRow struct { + Total int64 `json:"total"` + Critical int64 `json:"critical"` + High int64 `json:"high"` + Medium int64 `json:"medium"` + Low int64 `json:"low"` + Open int64 `json:"open"` + Resolved int64 `json:"resolved"` +} + +func (q *Queries) GetVulnerabilityMetrics(ctx context.Context, projectID uuid.UUID) (GetVulnerabilityMetricsRow, error) { + row := q.db.QueryRowContext(ctx, getVulnerabilityMetrics, projectID) + var i GetVulnerabilityMetricsRow + err := row.Scan( + &i.Total, + &i.Critical, + &i.High, + &i.Medium, + &i.Low, + &i.Open, + &i.Resolved, + ) + return i, err +} + +const getVulnerabilityProjectID = `-- name: GetVulnerabilityProjectID :one +SELECT project_id FROM vulnerabilities WHERE id = $1 +` + +func (q *Queries) GetVulnerabilityProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) { + row := q.db.QueryRowContext(ctx, getVulnerabilityProjectID, id) + var project_id uuid.UUID + err := row.Scan(&project_id) + return project_id, err +} + +const listComplianceFrameworks = `-- name: ListComplianceFrameworks :many +SELECT id, name, description, version, enabled, created_at +FROM compliance_frameworks +WHERE enabled = true +ORDER BY name +` + +type ListComplianceFrameworksRow struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description sql.NullString `json:"description"` + Version string `json:"version"` + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"created_at"` +} + +func (q *Queries) ListComplianceFrameworks(ctx context.Context) ([]ListComplianceFrameworksRow, error) { + rows, err := q.db.QueryContext(ctx, listComplianceFrameworks) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListComplianceFrameworksRow{} + for rows.Next() { + var i ListComplianceFrameworksRow + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.Version, + &i.Enabled, + &i.CreatedAt, + ); 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 listVulnerabilities = `-- name: ListVulnerabilities :many +SELECT id, type, severity, title, description, service_id, status, found_at, resolved_at +FROM vulnerabilities +WHERE project_id = $1 +ORDER BY + CASE severity + WHEN 'critical' THEN 1 + WHEN 'high' THEN 2 + WHEN 'medium' THEN 3 + WHEN 'low' THEN 4 + END, + found_at DESC +` + +type ListVulnerabilitiesRow struct { + ID uuid.UUID `json:"id"` + Type string `json:"type"` + Severity string `json:"severity"` + Title string `json:"title"` + Description sql.NullString `json:"description"` + ServiceID uuid.NullUUID `json:"service_id"` + Status string `json:"status"` + FoundAt time.Time `json:"found_at"` + ResolvedAt sql.NullTime `json:"resolved_at"` +} + +func (q *Queries) ListVulnerabilities(ctx context.Context, projectID uuid.UUID) ([]ListVulnerabilitiesRow, error) { + rows, err := q.db.QueryContext(ctx, listVulnerabilities, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListVulnerabilitiesRow{} + for rows.Next() { + var i ListVulnerabilitiesRow + if err := rows.Scan( + &i.ID, + &i.Type, + &i.Severity, + &i.Title, + &i.Description, + &i.ServiceID, + &i.Status, + &i.FoundAt, + &i.ResolvedAt, + ); 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 serviceExistsInProject = `-- name: ServiceExistsInProject :one +SELECT EXISTS(SELECT 1 FROM services WHERE id = $1 AND project_id = $2) +` + +type ServiceExistsInProjectParams struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` +} + +func (q *Queries) ServiceExistsInProject(ctx context.Context, arg ServiceExistsInProjectParams) (bool, error) { + row := q.db.QueryRowContext(ctx, serviceExistsInProject, arg.ID, arg.ProjectID) + var exists bool + err := row.Scan(&exists) + return exists, err +} + +const updateVulnerabilityStatus = `-- name: UpdateVulnerabilityStatus :exec +UPDATE vulnerabilities SET status = $1, resolved_at = $2 WHERE id = $3 +` + +type UpdateVulnerabilityStatusParams struct { + Status string `json:"status"` + ResolvedAt sql.NullTime `json:"resolved_at"` + ID uuid.UUID `json:"id"` +} + +func (q *Queries) UpdateVulnerabilityStatus(ctx context.Context, arg UpdateVulnerabilityStatusParams) error { + _, err := q.db.ExecContext(ctx, updateVulnerabilityStatus, arg.Status, arg.ResolvedAt, arg.ID) + return err +} diff --git a/app/backend/sqlc/queries/auth.sql b/app/backend/sqlc/queries/auth.sql new file mode 100644 index 0000000..3fbc20b --- /dev/null +++ b/app/backend/sqlc/queries/auth.sql @@ -0,0 +1,37 @@ +-- name: GetUserByEmailForAuth :one +SELECT id, email, password_hash, name, COALESCE(avatar_url, '') AS avatar_url, is_admin, created_at +FROM users WHERE email = $1; + +-- name: CountUsers :one +SELECT COUNT(*) FROM users; + +-- name: CountUsersByEmail :one +SELECT COUNT(*) FROM users WHERE email = $1; + +-- name: UpsertLocalUser :one +INSERT INTO users (email, password_hash, name, avatar_url, is_admin) +VALUES ($1, $2, $3, NULLIF($4::varchar, ''), $5) +ON CONFLICT (email) DO UPDATE +SET name = EXCLUDED.name, + avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url), + updated_at = NOW() +RETURNING id, email, name, COALESCE(avatar_url, '') AS avatar_url, is_admin, created_at; + +-- name: InsertUserAdmin :one +INSERT INTO users (email, password_hash, name, is_admin) +VALUES ($1, $2, $3, $4) +RETURNING id, email, name, COALESCE(avatar_url, '') AS avatar_url, is_admin, created_at; + +-- name: InsertUser :one +INSERT INTO users (email, password_hash, name) +VALUES ($1, $2, $3) +RETURNING id, email, name, COALESCE(avatar_url, '') AS avatar_url, is_admin, created_at; + +-- name: GetUserByID :one +SELECT id, email, name, COALESCE(avatar_url, '') AS avatar_url, is_admin, created_at +FROM users WHERE id = $1; + +-- name: UpdateUserProfile :exec +UPDATE users +SET name = COALESCE($1::varchar, name), avatar_url = COALESCE($2::varchar, avatar_url) +WHERE id = $3; diff --git a/app/backend/sqlc/queries/security.sql b/app/backend/sqlc/queries/security.sql new file mode 100644 index 0000000..e42d04b --- /dev/null +++ b/app/backend/sqlc/queries/security.sql @@ -0,0 +1,78 @@ +-- name: GetProjectOwnerText :one +SELECT owner_id::text FROM projects WHERE id = $1; + +-- name: ServiceExistsInProject :one +SELECT EXISTS(SELECT 1 FROM services WHERE id = $1 AND project_id = $2); + +-- name: ListVulnerabilities :many +SELECT id, type, severity, title, description, service_id, status, found_at, resolved_at +FROM vulnerabilities +WHERE project_id = $1 +ORDER BY + CASE severity + WHEN 'critical' THEN 1 + WHEN 'high' THEN 2 + WHEN 'medium' THEN 3 + WHEN 'low' THEN 4 + END, + found_at DESC; + +-- name: UpdateVulnerabilityStatus :exec +UPDATE vulnerabilities SET status = $1, resolved_at = $2 WHERE id = $3; + +-- name: ComplianceFrameworkExists :one +SELECT EXISTS(SELECT 1 FROM compliance_frameworks WHERE id = $1); + +-- name: ListComplianceFrameworks :many +SELECT id, name, description, version, enabled, created_at +FROM compliance_frameworks +WHERE enabled = true +ORDER BY name; + +-- name: GetVulnerabilityMetrics :one +SELECT + COUNT(*) AS total, + COUNT(*) FILTER (WHERE severity = 'critical') AS critical, + COUNT(*) FILTER (WHERE severity = 'high') AS high, + COUNT(*) FILTER (WHERE severity = 'medium') AS medium, + COUNT(*) FILTER (WHERE severity = 'low') AS low, + COUNT(*) FILTER (WHERE status = 'open') AS open, + COUNT(*) FILTER (WHERE status = 'resolved') AS resolved +FROM vulnerabilities +WHERE project_id = $1; + +-- name: GetLatestSecurityScan :one +SELECT id, COALESCE((summary->>'score')::int, 0)::int AS score, started_at AS scanned_at, status +FROM security_scans +WHERE project_id = $1 +ORDER BY started_at DESC +LIMIT 1; + +-- name: GetLatestComplianceReport :one +SELECT overall_status, score, assessment_date +FROM compliance_reports +WHERE project_id = $1 +ORDER BY assessment_date DESC +LIMIT 1; + +-- name: CheckSecurityProjectAccess :one +SELECT EXISTS ( + SELECT 1 + FROM projects p + WHERE p.id = $1 + 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 + )) +); + +-- name: GetSecurityScanProjectID :one +SELECT project_id FROM security_scans WHERE id = $1; + +-- name: GetComplianceReportProjectID :one +SELECT project_id FROM compliance_reports WHERE id = $1; + +-- name: GetVulnerabilityProjectID :one +SELECT project_id FROM vulnerabilities WHERE id = $1; diff --git a/app/backend/sqlc/schema.sql b/app/backend/sqlc/schema.sql index e826b8b..4574e80 100644 --- a/app/backend/sqlc/schema.sql +++ b/app/backend/sqlc/schema.sql @@ -529,3 +529,64 @@ CREATE TABLE git_webhooks ( created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); + +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + avatar_url VARCHAR(500), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + is_admin BOOLEAN NOT NULL DEFAULT false +); + +CREATE TABLE compliance_frameworks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(100) NOT NULL UNIQUE, + description TEXT, + version VARCHAR(20) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +CREATE TABLE security_scans ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL, + service_id UUID, + scan_type VARCHAR(50) NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'running', + started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + completed_at TIMESTAMP WITH TIME ZONE, + summary JSONB DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +CREATE TABLE compliance_reports ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL, + framework_id UUID NOT NULL, + assessment_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + assessor VARCHAR(255), + overall_status VARCHAR(50) NOT NULL, + score INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +CREATE TABLE vulnerabilities ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + type VARCHAR(50) NOT NULL, + severity VARCHAR(20) NOT NULL, + title VARCHAR(255) NOT NULL, + description TEXT, + service_id UUID, + project_id UUID NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'open', + found_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + resolved_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +);