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

+54 -46
View File
@@ -2,6 +2,7 @@ package services
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -9,7 +10,6 @@ import (
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
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
// 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 {
Host string `json:"host"`
Port *int `json:"port,omitempty"`
@@ -30,14 +31,6 @@ type DBConnectionConfig struct {
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 {
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"`
@@ -88,15 +81,44 @@ func toDBConnectionSummary(row db.CoreDbConnection) DBConnectionSummary {
}
}
// dbxConnectionName derives the DBX-internal name. The project prefix keeps
// same-named connections in different projects from clobbering each other in
// DBX's shared storage.
// dbxConnectionName derives the DBX-internal name. The full project UUID
// prefix keeps same-named connections in different projects from colliding
// in DBX's shared storage.
func dbxConnectionName(projectID uuid.UUID, name string) string {
short := strings.ReplaceAll(projectID.String(), "-", "")[:8]
return "prj_" + short + "_" + name
return "prj_" + strings.ReplaceAll(projectID.String(), "-", "") + "_" + 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)
args := map[string]any{
"name": dbxConnectionName(row.ProjectID, row.Name),
@@ -113,7 +135,11 @@ func (s *PlatformService) dbxArgsFor(row db.CoreDbConnection) map[string]any {
args["username"] = cfg.Username
}
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 {
args["ssl"] = *cfg.SSL
@@ -121,32 +147,7 @@ func (s *PlatformService) dbxArgsFor(row db.CoreDbConnection) map[string]any {
if cfg.DriverProfile != "" {
args["driver_profile"] = cfg.DriverProfile
}
return args
}
// 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,
})
}
return args, nil
}
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 {
return nil, err
}
s.seedManagedConnections(ctx, projectID)
rows, err := s.repo.Queries().ListDBConnections(ctx, projectID)
if err != nil {
return nil, err
@@ -183,12 +183,16 @@ func (s *PlatformService) CreateDBConnection(ctx context.Context, actor *models.
if name == "" {
return DBConnectionSummary{}, errors.New("name must contain letters or digits")
}
sealedPassword, err := s.sealPassword(input.Password)
if err != nil {
return DBConnectionSummary{}, err
}
cfg := DBConnectionConfig{
Host: strings.TrimSpace(input.Host),
Port: input.Port,
Database: strings.TrimSpace(input.Database),
Username: strings.TrimSpace(input.Username),
Password: input.Password,
Password: sealedPassword,
SSL: input.SSL,
DriverProfile: strings.TrimSpace(input.DriverProfile),
}
@@ -269,7 +273,11 @@ func (s *PlatformService) connForTool(ctx context.Context, actor *models.Actor,
if s.dbx == nil {
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 row, nil
@@ -33,7 +33,6 @@ type PlatformService struct {
mailer *Mailer
publicURL string
dbx *dbx.Client
managedDBSeeds []ManagedDBSeed
hub *EventHub
enc *secrets.Encryptor
dispatcher *WebhookDispatcher
@@ -222,8 +221,8 @@ type InvitationSummary struct {
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 {
s := &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, managedDBSeeds: managedDBSeeds, hub: NewEventHub(), enc: enc}
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, hub: NewEventHub(), enc: enc}
if enc != nil {
s.dispatcher = NewWebhookDispatcher(repo.Queries(), enc, logger)
s.dispatcher.Start(context.Background())