fix: address PR #1 security review — drop platform seeds, seal db passwords

- Remove managed connection seeding: platform-postgres/dragonfly carried
  the backend's own DATABASE_URL/DRAGONFLY_URL into tenant projects where
  any member could run arbitrary DBX queries against core tables
  (Codex P1). NewPlatformService loses the seeds param; firstPartyDBSeeds
  deleted.
- Seal db_connections passwords with the existing AES-256-GCM encryptor
  (enc:v1:<b64> marker); decrypted only in dbxArgsFor. Legacy plaintext
  values still read, but migration 00008 strips them (Codex P1).
- Migration 00008: delete is_managed rows, drop plaintext password keys.
- dbxConnectionName uses the full project UUID — the 8-hex prefix could
  collide and alias another project's credentials (Codex P2).
- ci.yml triggers on master, not the nonexistent main/develop branches.
- demo-mode stubs renamed to user-managed connections; CHANGELOG notes
  under Unreleased.

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-17 20:39:31 +02:00
1 parent 55edbf8b56
commit fffac52295
7 files changed
+85 -95

No files matched your search

+2 -2
View File
@@ -2,9 +2,9 @@ name: CI
on: on:
push: push:
branches: [main, develop] branches: [master]
pull_request: pull_request:
branches: [main, develop] branches: [master]
jobs: jobs:
backend-tests: backend-tests:
+6
View File
@@ -4,6 +4,12 @@ All notable changes to Primora. Format follows [Keep a Changelog](https://keepac
## [Unreleased] ## [Unreleased]
### Security
- **Managed platform connections removed** — `platform-postgres`/`platform-dragonfly` seeds carried the backend's own `DATABASE_URL`/`DRAGONFLY_URL` into every project, where any member could run arbitrary queries through DBX against `core` tables. The seeds are gone and migration 00008 deletes existing managed rows. Operators keep direct `psql`/`redis-cli` access.
- **Connection passwords encrypted at rest** — `core.db_connections.config.password` is now AES-256-GCM sealed (`enc:v1:` marker) via `PRIMORA_ENCRYPTION_KEY`; decrypted only when registering the connection with DBX. Migration 00008 strips pre-existing plaintext passwords.
- **DBX naming** — connection names now prefix the full project UUID; the previous 8-hex prefix could collide across projects and alias the wrong credentials.
## [0.5.0] - 2026-09-16 ## [0.5.0] - 2026-09-16
### Added ### Added
@@ -0,0 +1,11 @@
-- +goose Up
-- Managed seeds stored the platform's own DATABASE_URL/DRAGONFLY_URL in
-- tenant-visible rows; the feature is removed, so drop the leaked rows.
DELETE FROM core.db_connections WHERE is_managed;
-- Passwords are AES-GCM sealed app-side from now on; strip any plaintext
-- password left in existing configs (users re-enter on next edit).
UPDATE core.db_connections SET config = config - 'password' WHERE config ? 'password';
-- +goose Down
-- Deleted managed rows and stripped passwords are not recoverable.
SELECT 1;
+1 -35
View File
@@ -6,11 +6,8 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"net/url"
"os" "os"
"os/signal" "os/signal"
"strconv"
"strings"
"syscall" "syscall"
"time" "time"
@@ -94,7 +91,7 @@ func Bootstrap(ctx context.Context) (*App, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("encryption key: %w", err) return nil, fmt.Errorf("encryption key: %w", err)
} }
platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL"), dbxClient, firstPartyDBSeeds(cfg), encryptor, logger) platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL"), dbxClient, encryptor, logger)
if cfg.Env == "production" { if cfg.Env == "production" {
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
@@ -212,37 +209,6 @@ func (a *App) Run() error {
return nil return nil
} }
// firstPartyDBSeeds turns the platform's own Postgres and Dragonfly URLs into
// managed connection seeds registered on every project.
func firstPartyDBSeeds(cfg config.Config) []services.ManagedDBSeed {
var seeds []services.ManagedDBSeed
if u, err := url.Parse(cfg.DatabaseURL); err == nil && u.Hostname() != "" {
cfg2 := services.DBConnectionConfig{
Host: u.Hostname(),
Database: strings.TrimPrefix(u.Path, "/"),
Username: u.User.Username(),
}
if p, ok := u.User.Password(); ok {
cfg2.Password = p
}
if port, err := strconv.Atoi(u.Port()); err == nil {
cfg2.Port = &port
}
seeds = append(seeds, services.ManagedDBSeed{Name: "platform-postgres", DBType: "postgres", Config: cfg2})
}
if u, err := url.Parse(cfg.DragonflyURL); err == nil && u.Hostname() != "" {
cfg2 := services.DBConnectionConfig{Host: u.Hostname()}
if p, ok := u.User.Password(); ok {
cfg2.Password = p
}
if port, err := strconv.Atoi(u.Port()); err == nil {
cfg2.Port = &port
}
seeds = append(seeds, services.ManagedDBSeed{Name: "platform-dragonfly", DBType: "redis", Config: cfg2})
}
return seeds
}
func (a *App) Close() error { func (a *App) Close() error {
if a.sweepCancel != nil { if a.sweepCancel != nil {
a.sweepCancel() a.sweepCancel()
+54 -46
View File
@@ -2,6 +2,7 @@ package services
import ( import (
"context" "context"
"encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -9,7 +10,6 @@ import (
"strings" "strings"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
db "github.com/tdvorak/primora/apps/backend/internal/database/db" db "github.com/tdvorak/primora/apps/backend/internal/database/db"
@@ -19,7 +19,8 @@ import (
// DBConnectionConfig holds the server-side credential payload for a saved // DBConnectionConfig holds the server-side credential payload for a saved
// connection. It is stored in core.db_connections.config and never serialized // connection. It is stored in core.db_connections.config and never serialized
// to the client — see DBConnectionSummary. // to the client — see DBConnectionSummary. Password is AES-GCM sealed
// (enc:v1:<base64>) before persisting; openPassword restores it.
type DBConnectionConfig struct { type DBConnectionConfig struct {
Host string `json:"host"` Host string `json:"host"`
Port *int `json:"port,omitempty"` Port *int `json:"port,omitempty"`
@@ -30,14 +31,6 @@ type DBConnectionConfig struct {
DriverProfile string `json:"driver_profile,omitempty"` DriverProfile string `json:"driver_profile,omitempty"`
} }
// ManagedDBSeed describes a first-party connection auto-registered on every
// project (the platform's own Postgres and Dragonfly).
type ManagedDBSeed struct {
Name string
DBType string
Config DBConnectionConfig
}
type CreateDBConnectionInput struct { type CreateDBConnectionInput struct {
Name string `json:"name" validate:"required,min=1"` Name string `json:"name" validate:"required,min=1"`
DBType string `json:"db_type" validate:"required,oneof=postgres mysql sqlite redis mongodb sqlserver duckdb clickhouse cassandra"` DBType string `json:"db_type" validate:"required,oneof=postgres mysql sqlite redis mongodb sqlserver duckdb clickhouse cassandra"`
@@ -88,15 +81,44 @@ func toDBConnectionSummary(row db.CoreDbConnection) DBConnectionSummary {
} }
} }
// dbxConnectionName derives the DBX-internal name. The project prefix keeps // dbxConnectionName derives the DBX-internal name. The full project UUID
// same-named connections in different projects from clobbering each other in // prefix keeps same-named connections in different projects from colliding
// DBX's shared storage. // in DBX's shared storage.
func dbxConnectionName(projectID uuid.UUID, name string) string { func dbxConnectionName(projectID uuid.UUID, name string) string {
short := strings.ReplaceAll(projectID.String(), "-", "")[:8] return "prj_" + strings.ReplaceAll(projectID.String(), "-", "") + "_" + name
return "prj_" + short + "_" + name
} }
func (s *PlatformService) dbxArgsFor(row db.CoreDbConnection) map[string]any { // encPasswordPrefix marks a sealed password inside the JSONB config. Values
// without the prefix are legacy plaintext from pre-encryption rows.
const encPasswordPrefix = "enc:v1:"
func (s *PlatformService) sealPassword(plain string) (string, error) {
if plain == "" {
return "", nil
}
sealed, err := s.enc.Encrypt([]byte(plain))
if err != nil {
return "", fmt.Errorf("encrypt password: %w", err)
}
return encPasswordPrefix + base64.StdEncoding.EncodeToString(sealed), nil
}
func (s *PlatformService) openPassword(stored string) (string, error) {
if !strings.HasPrefix(stored, encPasswordPrefix) {
return stored, nil
}
sealed, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(stored, encPasswordPrefix))
if err != nil {
return "", fmt.Errorf("decode sealed password: %w", err)
}
plain, err := s.enc.Decrypt(sealed)
if err != nil {
return "", fmt.Errorf("decrypt password: %w", err)
}
return string(plain), nil
}
func (s *PlatformService) dbxArgsFor(row db.CoreDbConnection) (map[string]any, error) {
cfg := parseDBConnectionConfig(row.Config) cfg := parseDBConnectionConfig(row.Config)
args := map[string]any{ args := map[string]any{
"name": dbxConnectionName(row.ProjectID, row.Name), "name": dbxConnectionName(row.ProjectID, row.Name),
@@ -113,7 +135,11 @@ func (s *PlatformService) dbxArgsFor(row db.CoreDbConnection) map[string]any {
args["username"] = cfg.Username args["username"] = cfg.Username
} }
if cfg.Password != "" { if cfg.Password != "" {
args["password"] = cfg.Password plain, err := s.openPassword(cfg.Password)
if err != nil {
return nil, err
}
args["password"] = plain
} }
if cfg.SSL != nil { if cfg.SSL != nil {
args["ssl"] = *cfg.SSL args["ssl"] = *cfg.SSL
@@ -121,32 +147,7 @@ func (s *PlatformService) dbxArgsFor(row db.CoreDbConnection) map[string]any {
if cfg.DriverProfile != "" { if cfg.DriverProfile != "" {
args["driver_profile"] = cfg.DriverProfile args["driver_profile"] = cfg.DriverProfile
} }
return args return args, nil
}
// seedManagedConnections registers the platform's own Postgres and Dragonfly
// on the project. Runs on list so existing projects pick them up too.
func (s *PlatformService) seedManagedConnections(ctx context.Context, projectID uuid.UUID) {
for _, seed := range s.managedDBSeeds {
_, err := s.repo.Queries().GetDBConnectionByName(ctx, db.GetDBConnectionByNameParams{
ProjectID: projectID,
Name: seed.Name,
})
if err == nil {
continue
}
if !errors.Is(err, pgx.ErrNoRows) {
continue
}
cfgBytes, _ := json.Marshal(seed.Config)
_, _ = s.repo.Queries().CreateDBConnection(ctx, db.CreateDBConnectionParams{
ProjectID: projectID,
Name: seed.Name,
DbType: seed.DBType,
Config: cfgBytes,
IsManaged: true,
})
}
} }
func (s *PlatformService) DBXStatus(ctx context.Context, actor *models.Actor) (map[string]any, error) { func (s *PlatformService) DBXStatus(ctx context.Context, actor *models.Actor) (map[string]any, error) {
@@ -163,7 +164,6 @@ func (s *PlatformService) ListDBConnections(ctx context.Context, actor *models.A
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil { if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return nil, err return nil, err
} }
s.seedManagedConnections(ctx, projectID)
rows, err := s.repo.Queries().ListDBConnections(ctx, projectID) rows, err := s.repo.Queries().ListDBConnections(ctx, projectID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -183,12 +183,16 @@ func (s *PlatformService) CreateDBConnection(ctx context.Context, actor *models.
if name == "" { if name == "" {
return DBConnectionSummary{}, errors.New("name must contain letters or digits") return DBConnectionSummary{}, errors.New("name must contain letters or digits")
} }
sealedPassword, err := s.sealPassword(input.Password)
if err != nil {
return DBConnectionSummary{}, err
}
cfg := DBConnectionConfig{ cfg := DBConnectionConfig{
Host: strings.TrimSpace(input.Host), Host: strings.TrimSpace(input.Host),
Port: input.Port, Port: input.Port,
Database: strings.TrimSpace(input.Database), Database: strings.TrimSpace(input.Database),
Username: strings.TrimSpace(input.Username), Username: strings.TrimSpace(input.Username),
Password: input.Password, Password: sealedPassword,
SSL: input.SSL, SSL: input.SSL,
DriverProfile: strings.TrimSpace(input.DriverProfile), DriverProfile: strings.TrimSpace(input.DriverProfile),
} }
@@ -269,7 +273,11 @@ func (s *PlatformService) connForTool(ctx context.Context, actor *models.Actor,
if s.dbx == nil { if s.dbx == nil {
return db.CoreDbConnection{}, errors.New("dbx not configured") return db.CoreDbConnection{}, errors.New("dbx not configured")
} }
if err := s.dbx.EnsureConnection(ctx, dbxConnectionName(projectID, row.Name), s.dbxArgsFor(row)); err != nil { args, err := s.dbxArgsFor(row)
if err != nil {
return db.CoreDbConnection{}, err
}
if err := s.dbx.EnsureConnection(ctx, dbxConnectionName(projectID, row.Name), args); err != nil {
return db.CoreDbConnection{}, err return db.CoreDbConnection{}, err
} }
return row, nil return row, nil
@@ -33,7 +33,6 @@ type PlatformService struct {
mailer *Mailer mailer *Mailer
publicURL string publicURL string
dbx *dbx.Client dbx *dbx.Client
managedDBSeeds []ManagedDBSeed
hub *EventHub hub *EventHub
enc *secrets.Encryptor enc *secrets.Encryptor
dispatcher *WebhookDispatcher dispatcher *WebhookDispatcher
@@ -222,8 +221,8 @@ type InvitationSummary struct {
Status string `json:"status"` Status string `json:"status"`
} }
func NewPlatformService(repo *repositories.CoreRepository, store *storage.LocalStore, mailer *Mailer, publicURL string, dbxClient *dbx.Client, managedDBSeeds []ManagedDBSeed, enc *secrets.Encryptor, logger *slog.Logger) *PlatformService { func NewPlatformService(repo *repositories.CoreRepository, store *storage.LocalStore, mailer *Mailer, publicURL string, dbxClient *dbx.Client, enc *secrets.Encryptor, logger *slog.Logger) *PlatformService {
s := &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, managedDBSeeds: managedDBSeeds, hub: NewEventHub(), enc: enc} s := &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, hub: NewEventHub(), enc: enc}
if enc != nil { if enc != nil {
s.dispatcher = NewWebhookDispatcher(repo.Queries(), enc, logger) s.dispatcher = NewWebhookDispatcher(repo.Queries(), enc, logger)
s.dispatcher.Start(context.Background()) s.dispatcher.Start(context.Background())
+9 -9
View File
@@ -340,27 +340,27 @@ let dbConnections: DBConnection[] = [
{ {
id: "dbc-1", id: "dbc-1",
project_id: "proj-1", project_id: "proj-1",
name: "platform-postgres", name: "warehouse",
db_type: "postgres", db_type: "postgres",
host: "postgres", host: "db.example.internal",
port: 5432, port: 5432,
database: "primora", database: "analytics",
username: "primora", username: "primora_read",
ssl: null, ssl: true,
is_managed: true, is_managed: false,
has_password: true, has_password: true,
}, },
{ {
id: "dbc-2", id: "dbc-2",
project_id: "proj-1", project_id: "proj-1",
name: "platform-dragonfly", name: "cache",
db_type: "redis", db_type: "redis",
host: "dragonfly", host: "redis.example.internal",
port: 6379, port: 6379,
database: "", database: "",
username: "", username: "",
ssl: null, ssl: null,
is_managed: true, is_managed: false,
has_password: false, has_password: false,
}, },
]; ];