Compare commits

...

4 Commits

Author SHA1 Message Date
Rostislav Dugin
d521e2abc6 FIX (slack): Add request timeout for 30 seconds 2025-11-23 18:19:28 +03:00
Rostislav Dugin
82eca7501b FEATURE (security): Clean PostgreSQL creds after restore 2025-11-21 20:30:12 +03:00
Rostislav Dugin
51866437fd FEATURE (secutiry): Add read-only user creation before Postgresus backups 2025-11-21 19:14:13 +03:00
Rostislav Dugin
244a56d1bb FEATURE (secrets): Move secrets to the secret.key file instead of DB 2025-11-19 18:53:58 +03:00
39 changed files with 1368 additions and 125 deletions

3
.gitignore vendored
View File

@@ -4,4 +4,5 @@ postgresus-data/
pgdata/
docker-compose.yml
node_modules/
.idea
.idea
/articles

View File

@@ -58,12 +58,12 @@
- **SSL support**: Secure connections available
- **Easy restoration**: One-click restore from any backup
### 🔒 **Backup Encryption** <a href="https://postgresus.com/encryption">(docs)</a>
### 🔒 **Enterprise-grade security** <a href="https://postgresus.com/security">(docs)</a>
- **AES-256-GCM encryption**: Enterprise-grade protection for backup files
- **Zero-trust storage**: Encrypted backups are useless so you can keep in shared storages like S3, Azure Blob Storage, etc.
- **Optionality**: Encrypted backups are optional and can be enabled or disabled if you wish
- **Download unencrypted**: You can still download unencrypted backups via the 'Download' button to use them in `pg_restore` or other tools.
- **Zero-trust storage**: Backups are encrypted and they are useless to attackers, so you can keep them in shared storages like S3, Azure Blob Storage, etc.
- **Encryption for secrets**: Any sensitive data is encrypted and never exposed, even in logs or error messages
- **Read-only user**: Postgresus uses by default a read-only user for backups and never stores anything that can change your data
### 👥 **Suitable for Teams** <a href="https://postgresus.com/access-management">(docs)</a>

View File

@@ -18,6 +18,7 @@ import (
backups_config "postgresus-backend/internal/features/backups/config"
"postgresus-backend/internal/features/databases"
"postgresus-backend/internal/features/disk"
"postgresus-backend/internal/features/encryption/secrets"
healthcheck_attempt "postgresus-backend/internal/features/healthcheck/attempt"
healthcheck_config "postgresus-backend/internal/features/healthcheck/config"
"postgresus-backend/internal/features/notifiers"
@@ -64,6 +65,12 @@ func main() {
os.Exit(1)
}
err = secrets.GetSecretKeyService().MigrateKeyFromDbToFileIfExist()
if err != nil {
log.Error("Failed to migrate secret key from database to file", "error", err)
os.Exit(1)
}
err = users_services.GetUserService().CreateInitialAdmin()
if err != nil {
log.Error("Failed to create initial admin", "error", err)

View File

@@ -26,8 +26,9 @@ type EnvVariables struct {
EnvMode env_utils.EnvMode `env:"ENV_MODE" required:"true"`
PostgresesInstallDir string `env:"POSTGRES_INSTALL_DIR"`
DataFolder string
TempFolder string
DataFolder string
TempFolder string
SecretKeyPath string
TestGoogleDriveClientID string `env:"TEST_GOOGLE_DRIVE_CLIENT_ID"`
TestGoogleDriveClientSecret string `env:"TEST_GOOGLE_DRIVE_CLIENT_SECRET"`
@@ -146,6 +147,7 @@ func loadEnvVariables() {
// (projectRoot/postgresus-data -> /postgresus-data)
env.DataFolder = filepath.Join(filepath.Dir(backendRoot), "postgresus-data", "backups")
env.TempFolder = filepath.Join(filepath.Dir(backendRoot), "postgresus-data", "temp")
env.SecretKeyPath = filepath.Join(filepath.Dir(backendRoot), "postgresus-data", "secret.key")
if env.IsTesting {
if env.TestPostgres12Port == "" {

View File

@@ -1,17 +1,18 @@
package backups
import (
"time"
audit_logs "postgresus-backend/internal/features/audit_logs"
"postgresus-backend/internal/features/backups/backups/usecases"
backups_config "postgresus-backend/internal/features/backups/config"
"postgresus-backend/internal/features/databases"
encryption_secrets "postgresus-backend/internal/features/encryption/secrets"
"postgresus-backend/internal/features/notifiers"
"postgresus-backend/internal/features/storages"
users_repositories "postgresus-backend/internal/features/users/repositories"
workspaces_services "postgresus-backend/internal/features/workspaces/services"
"postgresus-backend/internal/util/encryption"
"postgresus-backend/internal/util/logger"
"time"
)
var backupRepository = &BackupRepository{}
@@ -25,7 +26,7 @@ var backupService = &BackupService{
notifiers.GetNotifierService(),
notifiers.GetNotifierService(),
backups_config.GetBackupConfigService(),
users_repositories.GetSecretKeyRepository(),
encryption_secrets.GetSecretKeyService(),
encryption.GetFieldEncryptor(),
usecases.GetCreateBackupUsecase(),
logger.GetLogger(),

View File

@@ -7,19 +7,20 @@ import (
"fmt"
"io"
"log/slog"
"slices"
"strings"
"time"
audit_logs "postgresus-backend/internal/features/audit_logs"
"postgresus-backend/internal/features/backups/backups/encryption"
backups_config "postgresus-backend/internal/features/backups/config"
"postgresus-backend/internal/features/databases"
encryption_secrets "postgresus-backend/internal/features/encryption/secrets"
"postgresus-backend/internal/features/notifiers"
"postgresus-backend/internal/features/storages"
users_models "postgresus-backend/internal/features/users/models"
users_repositories "postgresus-backend/internal/features/users/repositories"
workspaces_services "postgresus-backend/internal/features/workspaces/services"
util_encryption "postgresus-backend/internal/util/encryption"
"slices"
"strings"
"time"
"github.com/google/uuid"
)
@@ -31,7 +32,7 @@ type BackupService struct {
notifierService *notifiers.NotifierService
notificationSender NotificationSender
backupConfigService *backups_config.BackupConfigService
secretKeyRepo *users_repositories.SecretKeyRepository
secretKeyService *encryption_secrets.SecretKeyService
fieldEncryptor util_encryption.FieldEncryptor
createBackupUseCase CreateBackupUsecase
@@ -628,7 +629,7 @@ func (s *BackupService) getBackupReader(backupID uuid.UUID) (io.ReadCloser, erro
}
// Get master key
masterKey, err := s.secretKeyRepo.GetSecretKey()
masterKey, err := s.secretKeyService.GetSecretKey()
if err != nil {
if closeErr := fileReader.Close(); closeErr != nil {
s.logger.Error("Failed to close file reader", "error", closeErr)

View File

@@ -3,21 +3,22 @@ package backups
import (
"context"
"errors"
"strings"
"testing"
"time"
usecases_postgresql "postgresus-backend/internal/features/backups/backups/usecases/postgresql"
backups_config "postgresus-backend/internal/features/backups/config"
"postgresus-backend/internal/features/databases"
encryption_secrets "postgresus-backend/internal/features/encryption/secrets"
"postgresus-backend/internal/features/notifiers"
"postgresus-backend/internal/features/storages"
users_enums "postgresus-backend/internal/features/users/enums"
users_repositories "postgresus-backend/internal/features/users/repositories"
users_testing "postgresus-backend/internal/features/users/testing"
workspaces_services "postgresus-backend/internal/features/workspaces/services"
workspaces_testing "postgresus-backend/internal/features/workspaces/testing"
"postgresus-backend/internal/util/encryption"
"postgresus-backend/internal/util/logger"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
@@ -56,7 +57,7 @@ func Test_BackupExecuted_NotificationSent(t *testing.T) {
notifiers.GetNotifierService(),
mockNotificationSender,
backups_config.GetBackupConfigService(),
users_repositories.GetSecretKeyRepository(),
encryption_secrets.GetSecretKeyService(),
encryption.GetFieldEncryptor(),
&CreateFailedBackupUsecase{},
logger.GetLogger(),
@@ -104,7 +105,7 @@ func Test_BackupExecuted_NotificationSent(t *testing.T) {
notifiers.GetNotifierService(),
mockNotificationSender,
backups_config.GetBackupConfigService(),
users_repositories.GetSecretKeyRepository(),
encryption_secrets.GetSecretKeyService(),
encryption.GetFieldEncryptor(),
&CreateSuccessBackupUsecase{},
logger.GetLogger(),
@@ -129,7 +130,7 @@ func Test_BackupExecuted_NotificationSent(t *testing.T) {
notifiers.GetNotifierService(),
mockNotificationSender,
backups_config.GetBackupConfigService(),
users_repositories.GetSecretKeyRepository(),
encryption_secrets.GetSecretKeyService(),
encryption.GetFieldEncryptor(),
&CreateSuccessBackupUsecase{},
logger.GetLogger(),

View File

@@ -19,8 +19,8 @@ import (
backups_config "postgresus-backend/internal/features/backups/config"
"postgresus-backend/internal/features/databases"
pgtypes "postgresus-backend/internal/features/databases/databases/postgresql"
encryption_secrets "postgresus-backend/internal/features/encryption/secrets"
"postgresus-backend/internal/features/storages"
users_repositories "postgresus-backend/internal/features/users/repositories"
"postgresus-backend/internal/util/encryption"
"postgresus-backend/internal/util/tools"
@@ -34,16 +34,15 @@ const (
progressReportIntervalMB = 1.0
pgConnectTimeout = 30
compressionLevel = 5
defaultBackupLimit = 1000
exitCodeAccessViolation = -1073741819
exitCodeGenericError = 1
exitCodeConnectionError = 2
)
type CreatePostgresqlBackupUsecase struct {
logger *slog.Logger
secretKeyRepo *users_repositories.SecretKeyRepository
fieldEncryptor encryption.FieldEncryptor
logger *slog.Logger
secretKeyService *encryption_secrets.SecretKeyService
fieldEncryptor encryption.FieldEncryptor
}
// Execute creates a backup of the database
@@ -466,7 +465,7 @@ func (uc *CreatePostgresqlBackupUsecase) setupBackupEncryption(
return nil, nil, metadata, fmt.Errorf("failed to generate nonce: %w", err)
}
masterKey, err := uc.secretKeyRepo.GetSecretKey()
masterKey, err := uc.secretKeyService.GetSecretKey()
if err != nil {
return nil, nil, metadata, fmt.Errorf("failed to get master key: %w", err)
}

View File

@@ -1,14 +1,14 @@
package usecases_postgresql
import (
users_repositories "postgresus-backend/internal/features/users/repositories"
"postgresus-backend/internal/features/encryption/secrets"
"postgresus-backend/internal/util/encryption"
"postgresus-backend/internal/util/logger"
)
var createPostgresqlBackupUsecase = &CreatePostgresqlBackupUsecase{
logger.GetLogger(),
users_repositories.GetSecretKeyRepository(),
secrets.GetSecretKeyService(),
encryption.GetFieldEncryptor(),
}

View File

@@ -26,7 +26,8 @@ func (c *DatabaseController) RegisterRoutes(router *gin.RouterGroup) {
router.POST("/databases/test-connection-direct", c.TestDatabaseConnectionDirect)
router.POST("/databases/:id/copy", c.CopyDatabase)
router.GET("/databases/notifier/:id/is-using", c.IsNotifierUsing)
router.POST("/databases/is-readonly", c.IsUserReadOnly)
router.POST("/databases/create-readonly-user", c.CreateReadOnlyUser)
}
// CreateDatabase
@@ -330,3 +331,76 @@ func (c *DatabaseController) CopyDatabase(ctx *gin.Context) {
ctx.JSON(http.StatusCreated, copiedDatabase)
}
// IsUserReadOnly
// @Summary Check if database user is read-only
// @Description Check if current database credentials have only read (SELECT) privileges
// @Tags databases
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body Database true "Database configuration to check"
// @Success 200 {object} IsReadOnlyResponse
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Router /databases/is-readonly [post]
func (c *DatabaseController) IsUserReadOnly(ctx *gin.Context) {
user, ok := users_middleware.GetUserFromContext(ctx)
if !ok {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var request Database
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
isReadOnly, err := c.databaseService.IsUserReadOnly(user, &request)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, IsReadOnlyResponse{IsReadOnly: isReadOnly})
}
// CreateReadOnlyUser
// @Summary Create read-only database user
// @Description Create a new PostgreSQL user with read-only privileges for backup operations
// @Tags databases
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body Database true "Database configuration to create user for"
// @Success 200 {object} CreateReadOnlyUserResponse
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Router /databases/create-readonly-user [post]
func (c *DatabaseController) CreateReadOnlyUser(ctx *gin.Context) {
user, ok := users_middleware.GetUserFromContext(ctx)
if !ok {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var request Database
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
username, password, err := c.databaseService.CreateReadOnlyUser(user, &request)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, CreateReadOnlyUserResponse{
Username: username,
Password: password,
})
}

View File

@@ -8,6 +8,7 @@ import (
"postgresus-backend/internal/util/encryption"
"postgresus-backend/internal/util/tools"
"regexp"
"strings"
"time"
"github.com/google/uuid"
@@ -18,7 +19,6 @@ type PostgresqlDatabase struct {
ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;default:gen_random_uuid()"`
DatabaseID *uuid.UUID `json:"databaseId" gorm:"type:uuid;column:database_id"`
RestoreID *uuid.UUID `json:"restoreId" gorm:"type:uuid;column:restore_id"`
Version tools.PostgresqlVersion `json:"version" gorm:"type:text;not null"`
@@ -106,6 +106,384 @@ func (p *PostgresqlDatabase) EncryptSensitiveFields(
return nil
}
// IsUserReadOnly checks if the database user has read-only privileges.
//
// This method performs a comprehensive security check by examining:
// - Role-level attributes (superuser, createrole, createdb)
// - Database-level privileges (CREATE, TEMP)
// - Table-level write permissions (INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER)
//
// A user is considered read-only only if they have ZERO write privileges
// across all three levels. This ensures the database user follows the
// principle of least privilege for backup operations.
func (p *PostgresqlDatabase) IsUserReadOnly(
ctx context.Context,
logger *slog.Logger,
encryptor encryption.FieldEncryptor,
databaseID uuid.UUID,
) (bool, error) {
password, err := decryptPasswordIfNeeded(p.Password, encryptor, databaseID)
if err != nil {
return false, fmt.Errorf("failed to decrypt password: %w", err)
}
connStr := buildConnectionStringForDB(p, *p.Database, password)
conn, err := pgx.Connect(ctx, connStr)
if err != nil {
return false, fmt.Errorf("failed to connect to database: %w", err)
}
defer func() {
if closeErr := conn.Close(ctx); closeErr != nil {
logger.Error("Failed to close connection", "error", closeErr)
}
}()
// LEVEL 1: Check role-level attributes
var isSuperuser, canCreateRole, canCreateDB bool
err = conn.QueryRow(ctx, `
SELECT
rolsuper,
rolcreaterole,
rolcreatedb
FROM pg_roles
WHERE rolname = current_user
`).Scan(&isSuperuser, &canCreateRole, &canCreateDB)
if err != nil {
return false, fmt.Errorf("failed to check role attributes: %w", err)
}
if isSuperuser || canCreateRole || canCreateDB {
return false, nil
}
// LEVEL 2: Check database-level privileges
var canCreate, canTemp bool
err = conn.QueryRow(ctx, `
SELECT
has_database_privilege(current_user, current_database(), 'CREATE') as can_create,
has_database_privilege(current_user, current_database(), 'TEMP') as can_temp
`).Scan(&canCreate, &canTemp)
if err != nil {
return false, fmt.Errorf("failed to check database privileges: %w", err)
}
if canCreate || canTemp {
return false, nil
}
// LEVEL 2.5: Check schema-level CREATE privileges
schemaRows, err := conn.Query(ctx, `
SELECT DISTINCT nspname
FROM pg_namespace n
WHERE has_schema_privilege(current_user, n.nspname, 'CREATE')
AND nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
`)
if err != nil {
return false, fmt.Errorf("failed to check schema privileges: %w", err)
}
defer schemaRows.Close()
// If user has CREATE privilege on any schema, they're not read-only
if schemaRows.Next() {
return false, nil
}
if err := schemaRows.Err(); err != nil {
return false, fmt.Errorf("error iterating schema privileges: %w", err)
}
// LEVEL 3: Check table-level write permissions
rows, err := conn.Query(ctx, `
SELECT DISTINCT privilege_type
FROM information_schema.role_table_grants
WHERE grantee = current_user
AND table_schema NOT IN ('pg_catalog', 'information_schema')
`)
if err != nil {
return false, fmt.Errorf("failed to check table privileges: %w", err)
}
defer rows.Close()
writePrivileges := map[string]bool{
"INSERT": true,
"UPDATE": true,
"DELETE": true,
"TRUNCATE": true,
"REFERENCES": true,
"TRIGGER": true,
}
for rows.Next() {
var privilege string
if err := rows.Scan(&privilege); err != nil {
return false, fmt.Errorf("failed to scan privilege: %w", err)
}
if writePrivileges[privilege] {
return false, nil
}
}
if err := rows.Err(); err != nil {
return false, fmt.Errorf("error iterating privileges: %w", err)
}
return true, nil
}
// CreateReadOnlyUser creates a new PostgreSQL user with read-only privileges.
//
// This method performs the following operations atomically in a single transaction:
// 1. Creates a PostgreSQL user with a UUID-based password
// 2. Grants CONNECT privilege on the database
// 3. Grants USAGE on all non-system schemas
// 4. Grants SELECT on all existing tables and sequences
// 5. Sets default privileges for future tables and sequences
//
// Security features:
// - Username format: "postgresus-{8-char-uuid}" for uniqueness
// - Password: Full UUID (36 characters) for strong entropy
// - Transaction safety: All operations rollback on any failure
// - Retry logic: Up to 3 attempts if username collision occurs
// - Pre-validation: Checks CREATEROLE privilege before starting transaction
func (p *PostgresqlDatabase) CreateReadOnlyUser(
ctx context.Context,
logger *slog.Logger,
encryptor encryption.FieldEncryptor,
databaseID uuid.UUID,
) (string, string, error) {
password, err := decryptPasswordIfNeeded(p.Password, encryptor, databaseID)
if err != nil {
return "", "", fmt.Errorf("failed to decrypt password: %w", err)
}
connStr := buildConnectionStringForDB(p, *p.Database, password)
conn, err := pgx.Connect(ctx, connStr)
if err != nil {
return "", "", fmt.Errorf("failed to connect to database: %w", err)
}
defer func() {
if closeErr := conn.Close(ctx); closeErr != nil {
logger.Error("Failed to close connection", "error", closeErr)
}
}()
// Pre-validate: Check if current user can create roles
var canCreateRole, isSuperuser bool
err = conn.QueryRow(ctx, `
SELECT rolcreaterole, rolsuper
FROM pg_roles
WHERE rolname = current_user
`).Scan(&canCreateRole, &isSuperuser)
if err != nil {
return "", "", fmt.Errorf("failed to check permissions: %w", err)
}
if !canCreateRole && !isSuperuser {
return "", "", errors.New("current database user lacks CREATEROLE privilege")
}
// Retry logic for username collision
maxRetries := 3
for attempt := 0; attempt < maxRetries; attempt++ {
username := fmt.Sprintf("postgresus-%s", uuid.New().String()[:8])
newPassword := uuid.New().String()
tx, err := conn.Begin(ctx)
if err != nil {
return "", "", fmt.Errorf("failed to begin transaction: %w", err)
}
success := false
defer func() {
if !success {
if rollbackErr := tx.Rollback(ctx); rollbackErr != nil {
logger.Error("Failed to rollback transaction", "error", rollbackErr)
}
}
}()
// Step 1: Create PostgreSQL user with LOGIN privilege
_, err = tx.Exec(
ctx,
fmt.Sprintf(`CREATE USER "%s" WITH PASSWORD '%s' LOGIN`, username, newPassword),
)
if err != nil {
if err.Error() != "" && attempt < maxRetries-1 {
continue
}
return "", "", fmt.Errorf("failed to create user: %w", err)
}
// Step 1.5: Revoke CREATE privilege from PUBLIC role on public schema
// This is necessary because all PostgreSQL users inherit CREATE privilege on the
// public schema through the PUBLIC role. This is a one-time operation that affects
// the entire database, making it more secure by default.
// Note: This only affects the public schema; other schemas are unaffected.
_, err = tx.Exec(ctx, `REVOKE CREATE ON SCHEMA public FROM PUBLIC`)
if err != nil {
logger.Error("Failed to revoke CREATE on public from PUBLIC", "error", err)
if !strings.Contains(err.Error(), "schema \"public\" does not exist") &&
!strings.Contains(err.Error(), "permission denied") {
return "", "", fmt.Errorf("failed to revoke CREATE from PUBLIC: %w", err)
}
}
// Now revoke from the specific user as well (belt and suspenders)
_, err = tx.Exec(ctx, fmt.Sprintf(`REVOKE CREATE ON SCHEMA public FROM "%s"`, username))
if err != nil {
logger.Error(
"Failed to revoke CREATE on public schema from user",
"error",
err,
"username",
username,
)
}
// Step 2: Grant database connection privilege and revoke TEMP
_, err = tx.Exec(
ctx,
fmt.Sprintf(`GRANT CONNECT ON DATABASE %s TO "%s"`, *p.Database, username),
)
if err != nil {
return "", "", fmt.Errorf("failed to grant connect privilege: %w", err)
}
// Revoke TEMP privilege from PUBLIC role (like CREATE on public schema, TEMP is granted to PUBLIC by default)
_, err = tx.Exec(ctx, fmt.Sprintf(`REVOKE TEMP ON DATABASE %s FROM PUBLIC`, *p.Database))
if err != nil {
logger.Warn("Failed to revoke TEMP from PUBLIC", "error", err)
}
// Also revoke from the specific user (belt and suspenders)
_, err = tx.Exec(
ctx,
fmt.Sprintf(`REVOKE TEMP ON DATABASE %s FROM "%s"`, *p.Database, username),
)
if err != nil {
logger.Warn("Failed to revoke TEMP privilege", "error", err, "username", username)
}
// Step 3: Discover all user-created schemas
rows, err := tx.Query(ctx, `
SELECT schema_name
FROM information_schema.schemata
WHERE schema_name NOT IN ('pg_catalog', 'information_schema')
`)
if err != nil {
return "", "", fmt.Errorf("failed to get schemas: %w", err)
}
var schemas []string
for rows.Next() {
var schema string
if err := rows.Scan(&schema); err != nil {
rows.Close()
return "", "", fmt.Errorf("failed to scan schema: %w", err)
}
schemas = append(schemas, schema)
}
rows.Close()
if err := rows.Err(); err != nil {
return "", "", fmt.Errorf("error iterating schemas: %w", err)
}
// Step 4: Grant USAGE on each schema and explicitly prevent CREATE
for _, schema := range schemas {
// Revoke CREATE specifically (handles inheritance from PUBLIC role)
_, err = tx.Exec(
ctx,
fmt.Sprintf(`REVOKE CREATE ON SCHEMA "%s" FROM "%s"`, schema, username),
)
if err != nil {
logger.Warn(
"Failed to revoke CREATE on schema",
"error",
err,
"schema",
schema,
"username",
username,
)
}
// Grant only USAGE (not CREATE)
_, err = tx.Exec(
ctx,
fmt.Sprintf(`GRANT USAGE ON SCHEMA "%s" TO "%s"`, schema, username),
)
if err != nil {
return "", "", fmt.Errorf("failed to grant usage on schema %s: %w", schema, err)
}
}
// Step 5: Grant SELECT on ALL existing tables and sequences
grantSelectSQL := fmt.Sprintf(`
DO $$
DECLARE
schema_rec RECORD;
BEGIN
FOR schema_rec IN
SELECT schema_name
FROM information_schema.schemata
WHERE schema_name NOT IN ('pg_catalog', 'information_schema')
LOOP
EXECUTE format('GRANT SELECT ON ALL TABLES IN SCHEMA %%I TO "%s"', schema_rec.schema_name);
EXECUTE format('GRANT SELECT ON ALL SEQUENCES IN SCHEMA %%I TO "%s"', schema_rec.schema_name);
END LOOP;
END $$;
`, username, username)
_, err = tx.Exec(ctx, grantSelectSQL)
if err != nil {
return "", "", fmt.Errorf("failed to grant select on tables: %w", err)
}
// Step 6: Set default privileges for FUTURE tables and sequences
defaultPrivilegesSQL := fmt.Sprintf(`
DO $$
DECLARE
schema_rec RECORD;
BEGIN
FOR schema_rec IN
SELECT schema_name
FROM information_schema.schemata
WHERE schema_name NOT IN ('pg_catalog', 'information_schema')
LOOP
EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %%I GRANT SELECT ON TABLES TO "%s"', schema_rec.schema_name);
EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %%I GRANT SELECT ON SEQUENCES TO "%s"', schema_rec.schema_name);
END LOOP;
END $$;
`, username, username)
_, err = tx.Exec(ctx, defaultPrivilegesSQL)
if err != nil {
return "", "", fmt.Errorf("failed to set default privileges: %w", err)
}
// Step 7: Verify user creation before committing
var verifyUsername string
err = tx.QueryRow(ctx, fmt.Sprintf(`SELECT rolname FROM pg_roles WHERE rolname = '%s'`, username)).
Scan(&verifyUsername)
if err != nil {
return "", "", fmt.Errorf("failed to verify user creation: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return "", "", fmt.Errorf("failed to commit transaction: %w", err)
}
success = true
logger.Info("Read-only user created successfully", "username", username)
return username, newPassword, nil
}
return "", "", errors.New("failed to generate unique username after 3 attempts")
}
// testSingleDatabaseConnection tests connection to a specific database for pg_dump
func testSingleDatabaseConnection(
logger *slog.Logger,

View File

@@ -0,0 +1,323 @@
package postgresql
import (
"context"
"fmt"
"log/slog"
"os"
"strconv"
"strings"
"testing"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"github.com/stretchr/testify/assert"
"postgresus-backend/internal/config"
"postgresus-backend/internal/util/tools"
)
func Test_IsUserReadOnly_AdminUser_ReturnsFalse(t *testing.T) {
env := config.GetEnv()
cases := []struct {
name string
version string
port string
}{
{"PostgreSQL 12", "12", env.TestPostgres12Port},
{"PostgreSQL 13", "13", env.TestPostgres13Port},
{"PostgreSQL 14", "14", env.TestPostgres14Port},
{"PostgreSQL 15", "15", env.TestPostgres15Port},
{"PostgreSQL 16", "16", env.TestPostgres16Port},
{"PostgreSQL 17", "17", env.TestPostgres17Port},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
container := connectToPostgresContainer(t, tc.port)
defer container.DB.Close()
pgModel := createPostgresModel(container)
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
ctx := context.Background()
isReadOnly, err := pgModel.IsUserReadOnly(ctx, logger, nil, uuid.New())
assert.NoError(t, err)
assert.False(t, isReadOnly, "Admin user should not be read-only")
})
}
}
func Test_CreateReadOnlyUser_UserCanReadButNotWrite(t *testing.T) {
env := config.GetEnv()
cases := []struct {
name string
version string
port string
}{
{"PostgreSQL 12", "12", env.TestPostgres12Port},
{"PostgreSQL 13", "13", env.TestPostgres13Port},
{"PostgreSQL 14", "14", env.TestPostgres14Port},
{"PostgreSQL 15", "15", env.TestPostgres15Port},
{"PostgreSQL 16", "16", env.TestPostgres16Port},
{"PostgreSQL 17", "17", env.TestPostgres17Port},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
container := connectToPostgresContainer(t, tc.port)
defer container.DB.Close()
_, err := container.DB.Exec(`
DROP TABLE IF EXISTS readonly_test CASCADE;
DROP TABLE IF EXISTS hack_table CASCADE;
DROP TABLE IF EXISTS future_table CASCADE;
CREATE TABLE readonly_test (
id SERIAL PRIMARY KEY,
data TEXT NOT NULL
);
INSERT INTO readonly_test (data) VALUES ('test1'), ('test2');
`)
assert.NoError(t, err)
pgModel := createPostgresModel(container)
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
ctx := context.Background()
username, password, err := pgModel.CreateReadOnlyUser(ctx, logger, nil, uuid.New())
assert.NoError(t, err)
assert.NotEmpty(t, username)
assert.NotEmpty(t, password)
assert.True(t, strings.HasPrefix(username, "postgresus-"))
readOnlyModel := &PostgresqlDatabase{
Version: pgModel.Version,
Host: pgModel.Host,
Port: pgModel.Port,
Username: username,
Password: password,
Database: pgModel.Database,
IsHttps: false,
}
isReadOnly, err := readOnlyModel.IsUserReadOnly(ctx, logger, nil, uuid.New())
assert.NoError(t, err)
assert.True(t, isReadOnly, "Created user should be read-only")
readOnlyDSN := fmt.Sprintf(
"host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
container.Host,
container.Port,
username,
password,
container.Database,
)
readOnlyConn, err := sqlx.Connect("postgres", readOnlyDSN)
assert.NoError(t, err)
defer readOnlyConn.Close()
var count int
err = readOnlyConn.Get(&count, "SELECT COUNT(*) FROM readonly_test")
assert.NoError(t, err)
assert.Equal(t, 2, count)
_, err = readOnlyConn.Exec("INSERT INTO readonly_test (data) VALUES ('should-fail')")
assert.Error(t, err)
assert.Contains(t, err.Error(), "permission denied")
_, err = readOnlyConn.Exec("UPDATE readonly_test SET data = 'hacked' WHERE id = 1")
assert.Error(t, err)
assert.Contains(t, err.Error(), "permission denied")
_, err = readOnlyConn.Exec("DELETE FROM readonly_test WHERE id = 1")
assert.Error(t, err)
assert.Contains(t, err.Error(), "permission denied")
_, err = readOnlyConn.Exec("CREATE TABLE hack_table (id INT)")
assert.Error(t, err)
assert.Contains(t, err.Error(), "permission denied")
// Clean up: Drop user with CASCADE to handle default privilege dependencies
_, err = container.DB.Exec(fmt.Sprintf(`DROP OWNED BY "%s" CASCADE`, username))
if err != nil {
t.Logf("Warning: Failed to drop owned objects: %v", err)
}
_, err = container.DB.Exec(fmt.Sprintf(`DROP USER IF EXISTS "%s"`, username))
assert.NoError(t, err)
})
}
}
func Test_ReadOnlyUser_FutureTables_HaveSelectPermission(t *testing.T) {
env := config.GetEnv()
container := connectToPostgresContainer(t, env.TestPostgres16Port)
defer container.DB.Close()
pgModel := createPostgresModel(container)
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
ctx := context.Background()
username, password, err := pgModel.CreateReadOnlyUser(ctx, logger, nil, uuid.New())
assert.NoError(t, err)
_, err = container.DB.Exec(`
CREATE TABLE future_table (
id SERIAL PRIMARY KEY,
data TEXT NOT NULL
);
INSERT INTO future_table (data) VALUES ('future_data');
`)
assert.NoError(t, err)
readOnlyDSN := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
container.Host, container.Port, username, password, container.Database)
readOnlyConn, err := sqlx.Connect("postgres", readOnlyDSN)
assert.NoError(t, err)
defer readOnlyConn.Close()
var data string
err = readOnlyConn.Get(&data, "SELECT data FROM future_table LIMIT 1")
assert.NoError(t, err)
assert.Equal(t, "future_data", data)
// Clean up: Drop user with CASCADE to handle default privilege dependencies
_, err = container.DB.Exec(fmt.Sprintf(`DROP OWNED BY "%s" CASCADE`, username))
if err != nil {
t.Logf("Warning: Failed to drop owned objects: %v", err)
}
_, err = container.DB.Exec(fmt.Sprintf(`DROP USER IF EXISTS "%s"`, username))
assert.NoError(t, err)
}
func Test_ReadOnlyUser_MultipleSchemas_AllAccessible(t *testing.T) {
env := config.GetEnv()
container := connectToPostgresContainer(t, env.TestPostgres16Port)
defer container.DB.Close()
_, err := container.DB.Exec(`
CREATE SCHEMA IF NOT EXISTS schema_a;
CREATE SCHEMA IF NOT EXISTS schema_b;
CREATE TABLE schema_a.table_a (id INT, data TEXT);
CREATE TABLE schema_b.table_b (id INT, data TEXT);
INSERT INTO schema_a.table_a VALUES (1, 'data_a');
INSERT INTO schema_b.table_b VALUES (2, 'data_b');
`)
assert.NoError(t, err)
pgModel := createPostgresModel(container)
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
ctx := context.Background()
username, password, err := pgModel.CreateReadOnlyUser(ctx, logger, nil, uuid.New())
assert.NoError(t, err)
readOnlyDSN := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
container.Host, container.Port, username, password, container.Database)
readOnlyConn, err := sqlx.Connect("postgres", readOnlyDSN)
assert.NoError(t, err)
defer readOnlyConn.Close()
var dataA string
err = readOnlyConn.Get(&dataA, "SELECT data FROM schema_a.table_a LIMIT 1")
assert.NoError(t, err)
assert.Equal(t, "data_a", dataA)
var dataB string
err = readOnlyConn.Get(&dataB, "SELECT data FROM schema_b.table_b LIMIT 1")
assert.NoError(t, err)
assert.Equal(t, "data_b", dataB)
// Clean up: Drop user with CASCADE to handle default privilege dependencies
_, err = container.DB.Exec(fmt.Sprintf(`DROP OWNED BY "%s" CASCADE`, username))
if err != nil {
t.Logf("Warning: Failed to drop owned objects: %v", err)
}
_, err = container.DB.Exec(fmt.Sprintf(`DROP USER IF EXISTS "%s"`, username))
assert.NoError(t, err)
_, err = container.DB.Exec(`DROP SCHEMA schema_a CASCADE; DROP SCHEMA schema_b CASCADE;`)
assert.NoError(t, err)
}
type PostgresContainer struct {
Host string
Port int
Username string
Password string
Database string
DB *sqlx.DB
}
func connectToPostgresContainer(t *testing.T, port string) *PostgresContainer {
dbName := "testdb"
password := "testpassword"
username := "testuser"
host := "localhost"
portInt, err := strconv.Atoi(port)
assert.NoError(t, err)
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
host, portInt, username, password, dbName)
db, err := sqlx.Connect("postgres", dsn)
assert.NoError(t, err)
var versionStr string
err = db.Get(&versionStr, "SELECT version()")
assert.NoError(t, err)
return &PostgresContainer{
Host: host,
Port: portInt,
Username: username,
Password: password,
Database: dbName,
DB: db,
}
}
func createPostgresModel(container *PostgresContainer) *PostgresqlDatabase {
var versionStr string
err := container.DB.Get(&versionStr, "SELECT version()")
if err != nil {
return nil
}
version := extractPostgresVersion(versionStr)
return &PostgresqlDatabase{
Version: version,
Host: container.Host,
Port: container.Port,
Username: container.Username,
Password: container.Password,
Database: &container.Database,
IsHttps: false,
}
}
func extractPostgresVersion(versionStr string) tools.PostgresqlVersion {
if strings.Contains(versionStr, "PostgreSQL 12") {
return tools.GetPostgresqlVersionEnum("12")
} else if strings.Contains(versionStr, "PostgreSQL 13") {
return tools.GetPostgresqlVersionEnum("13")
} else if strings.Contains(versionStr, "PostgreSQL 14") {
return tools.GetPostgresqlVersionEnum("14")
} else if strings.Contains(versionStr, "PostgreSQL 15") {
return tools.GetPostgresqlVersionEnum("15")
} else if strings.Contains(versionStr, "PostgreSQL 16") {
return tools.GetPostgresqlVersionEnum("16")
} else if strings.Contains(versionStr, "PostgreSQL 17") {
return tools.GetPostgresqlVersionEnum("17")
}
return tools.GetPostgresqlVersionEnum("16")
}

View File

@@ -0,0 +1,10 @@
package databases
type CreateReadOnlyUserResponse struct {
Username string `json:"username"`
Password string `json:"password"`
}
type IsReadOnlyResponse struct {
IsReadOnly bool `json:"isReadOnly"`
}

View File

@@ -1,6 +1,7 @@
package databases
import (
"context"
"errors"
"fmt"
"log/slog"
@@ -456,3 +457,148 @@ func (s *DatabaseService) OnBeforeWorkspaceDeletion(workspaceID uuid.UUID) error
return nil
}
func (s *DatabaseService) IsUserReadOnly(
user *users_models.User,
database *Database,
) (bool, error) {
var usingDatabase *Database
if database.ID != uuid.Nil {
existingDatabase, err := s.dbRepository.FindByID(database.ID)
if err != nil {
return false, err
}
if existingDatabase.WorkspaceID == nil {
return false, errors.New("cannot check user for database without workspace")
}
canAccess, _, err := s.workspaceService.CanUserAccessWorkspace(
*existingDatabase.WorkspaceID,
user,
)
if err != nil {
return false, err
}
if !canAccess {
return false, errors.New("insufficient permissions to access this database")
}
if database.WorkspaceID != nil && *existingDatabase.WorkspaceID != *database.WorkspaceID {
return false, errors.New("database does not belong to this workspace")
}
existingDatabase.Update(database)
if err := existingDatabase.Validate(); err != nil {
return false, err
}
usingDatabase = existingDatabase
} else {
if database.WorkspaceID != nil {
canAccess, _, err := s.workspaceService.CanUserAccessWorkspace(*database.WorkspaceID, user)
if err != nil {
return false, err
}
if !canAccess {
return false, errors.New("insufficient permissions to access this workspace")
}
}
usingDatabase = database
}
if usingDatabase.Type != DatabaseTypePostgres {
return false, errors.New("read-only check only supported for PostgreSQL databases")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
return usingDatabase.Postgresql.IsUserReadOnly(
ctx,
s.logger,
s.fieldEncryptor,
usingDatabase.ID,
)
}
func (s *DatabaseService) CreateReadOnlyUser(
user *users_models.User,
database *Database,
) (string, string, error) {
var usingDatabase *Database
if database.ID != uuid.Nil {
existingDatabase, err := s.dbRepository.FindByID(database.ID)
if err != nil {
return "", "", err
}
if existingDatabase.WorkspaceID == nil {
return "", "", errors.New("cannot create user for database without workspace")
}
canManage, err := s.workspaceService.CanUserManageDBs(*existingDatabase.WorkspaceID, user)
if err != nil {
return "", "", err
}
if !canManage {
return "", "", errors.New("insufficient permissions to manage this database")
}
if database.WorkspaceID != nil && *existingDatabase.WorkspaceID != *database.WorkspaceID {
return "", "", errors.New("database does not belong to this workspace")
}
existingDatabase.Update(database)
if err := existingDatabase.Validate(); err != nil {
return "", "", err
}
usingDatabase = existingDatabase
} else {
if database.WorkspaceID != nil {
canManage, err := s.workspaceService.CanUserManageDBs(*database.WorkspaceID, user)
if err != nil {
return "", "", err
}
if !canManage {
return "", "", errors.New("insufficient permissions to manage this workspace")
}
}
usingDatabase = database
}
if usingDatabase.Type != DatabaseTypePostgres {
return "", "", errors.New("read-only user creation only supported for PostgreSQL")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
username, password, err := usingDatabase.Postgresql.CreateReadOnlyUser(
ctx, s.logger, s.fieldEncryptor, usingDatabase.ID,
)
if err != nil {
return "", "", err
}
if usingDatabase.WorkspaceID != nil {
s.auditLogService.WriteAuditLog(
fmt.Sprintf(
"Read-only user created for database: %s (username: %s)",
usingDatabase.Name,
username,
),
&user.ID,
usingDatabase.WorkspaceID,
)
}
return username, password, nil
}

View File

@@ -0,0 +1,9 @@
package secrets
var secretKeyService = &SecretKeyService{
nil,
}
func GetSecretKeyService() *SecretKeyService {
return secretKeyService
}

View File

@@ -0,0 +1 @@
package secrets

View File

@@ -0,0 +1,73 @@
package secrets
import (
"errors"
"fmt"
"os"
"postgresus-backend/internal/config"
user_models "postgresus-backend/internal/features/users/models"
"postgresus-backend/internal/storage"
"github.com/google/uuid"
"gorm.io/gorm"
)
type SecretKeyService struct {
cachedKey *string
}
func (s *SecretKeyService) MigrateKeyFromDbToFileIfExist() error {
var secretKey user_models.SecretKey
err := storage.GetDb().First(&secretKey).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return fmt.Errorf("failed to check for secret key in database: %w", err)
}
if secretKey.Secret == "" {
return nil
}
secretKeyPath := config.GetEnv().SecretKeyPath
if err := os.WriteFile(secretKeyPath, []byte(secretKey.Secret), 0600); err != nil {
return fmt.Errorf("failed to write secret key to file: %w", err)
}
if err := storage.GetDb().Exec("DELETE FROM secret_keys").Error; err != nil {
return fmt.Errorf("failed to delete secret key from database: %w", err)
}
return nil
}
func (s *SecretKeyService) GetSecretKey() (string, error) {
if s.cachedKey != nil {
return *s.cachedKey, nil
}
secretKeyPath := config.GetEnv().SecretKeyPath
data, err := os.ReadFile(secretKeyPath)
if err != nil {
if os.IsNotExist(err) {
newKey := s.generateNewSecretKey()
if err := os.WriteFile(secretKeyPath, []byte(newKey), 0600); err != nil {
return "", fmt.Errorf("failed to write new secret key: %w", err)
}
s.cachedKey = &newKey
return newKey, nil
}
return "", fmt.Errorf("failed to read secret key file: %w", err)
}
key := string(data)
s.cachedKey = &key
return key, nil
}
func (s *SecretKeyService) generateNewSecretKey() string {
return uuid.New().String() + uuid.New().String()
}

View File

@@ -70,6 +70,7 @@ func (s *SlackNotifier) Send(
maxAttempts = 5
defaultBackoff = 2 * time.Second // when Retry-After header missing
backoffMultiplier = 1.5 // use exponential growth
requestTimeout = 30 * time.Second
)
var (
@@ -77,6 +78,10 @@ func (s *SlackNotifier) Send(
attempts = 0
)
client := &http.Client{
Timeout: requestTimeout,
}
for {
attempts++
@@ -92,7 +97,7 @@ func (s *SlackNotifier) Send(
req.Header.Set("Content-Type", "application/json; charset=utf-8")
req.Header.Set("Authorization", "Bearer "+botToken)
resp, err := http.DefaultClient.Do(req)
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("send slack message: %w", err)
}

View File

@@ -2,7 +2,6 @@ package models
import (
"postgresus-backend/internal/features/backups/backups"
"postgresus-backend/internal/features/databases/databases/postgresql"
"postgresus-backend/internal/features/restores/enums"
"time"
@@ -16,8 +15,6 @@ type Restore struct {
BackupID uuid.UUID `json:"backupId" gorm:"column:backup_id;type:uuid;not null"`
Backup *backups.Backup
Postgresql *postgresql.PostgresqlDatabase `json:"postgresql,omitempty" gorm:"foreignKey:RestoreID"`
FailMessage *string `json:"failMessage" gorm:"column:fail_message"`
RestoreDurationMs int64 `json:"restoreDurationMs" gorm:"column:restore_duration_ms;default:0"`

View File

@@ -32,7 +32,6 @@ func (r *RestoreRepository) FindByBackupID(backupID uuid.UUID) ([]*models.Restor
if err := storage.
GetDb().
Preload("Backup").
Preload("Postgresql").
Where("backup_id = ?", backupID).
Order("created_at DESC").
Find(&restores).Error; err != nil {
@@ -48,7 +47,6 @@ func (r *RestoreRepository) FindByID(id uuid.UUID) (*models.Restore, error) {
if err := storage.
GetDb().
Preload("Backup").
Preload("Postgresql").
Where("id = ?", id).
First(&restore).Error; err != nil {
return nil, err
@@ -63,7 +61,6 @@ func (r *RestoreRepository) FindByStatus(status enums.RestoreStatus) ([]*models.
if err := storage.
GetDb().
Preload("Backup").
Preload("Postgresql").
Where("status = ?", status).
Order("created_at DESC").
Find(&restores).Error; err != nil {

View File

@@ -191,15 +191,9 @@ func (s *RestoreService) RestoreBackup(
return err
}
// Set the RestoreID on the PostgreSQL database and save it
if requestDTO.PostgresqlDatabase != nil {
requestDTO.PostgresqlDatabase.RestoreID = &restore.ID
restore.Postgresql = requestDTO.PostgresqlDatabase
// Save the restore again to include the postgresql database
if err := s.restoreRepository.Save(&restore); err != nil {
return err
}
// Save the restore again to include the postgresql database
if err := s.restoreRepository.Save(&restore); err != nil {
return err
}
storage, err := s.storageService.GetStorageByID(backup.StorageID)
@@ -216,10 +210,15 @@ func (s *RestoreService) RestoreBackup(
start := time.Now().UTC()
restoringToDB := &databases.Database{
Postgresql: requestDTO.PostgresqlDatabase,
}
err = s.restoreBackupUsecase.Execute(
backupConfig,
restore,
database,
restoringToDB,
backup,
storage,
)

View File

@@ -1,13 +1,13 @@
package usecases_postgresql
import (
users_repositories "postgresus-backend/internal/features/users/repositories"
"postgresus-backend/internal/features/encryption/secrets"
"postgresus-backend/internal/util/logger"
)
var restorePostgresqlBackupUsecase = &RestorePostgresqlBackupUsecase{
logger.GetLogger(),
users_repositories.GetSecretKeyRepository(),
secrets.GetSecretKeyService(),
}
func GetRestorePostgresqlBackupUsecase() *RestorePostgresqlBackupUsecase {

View File

@@ -20,9 +20,9 @@ import (
backups_config "postgresus-backend/internal/features/backups/config"
"postgresus-backend/internal/features/databases"
pgtypes "postgresus-backend/internal/features/databases/databases/postgresql"
encryption_secrets "postgresus-backend/internal/features/encryption/secrets"
"postgresus-backend/internal/features/restores/models"
"postgresus-backend/internal/features/storages"
users_repositories "postgresus-backend/internal/features/users/repositories"
util_encryption "postgresus-backend/internal/util/encryption"
files_utils "postgresus-backend/internal/util/files"
"postgresus-backend/internal/util/tools"
@@ -31,18 +31,19 @@ import (
)
type RestorePostgresqlBackupUsecase struct {
logger *slog.Logger
secretKeyRepo *users_repositories.SecretKeyRepository
logger *slog.Logger
secretKeyService *encryption_secrets.SecretKeyService
}
func (uc *RestorePostgresqlBackupUsecase) Execute(
database *databases.Database,
originalDB *databases.Database,
restoringToDB *databases.Database,
backupConfig *backups_config.BackupConfig,
restore models.Restore,
backup *backups.Backup,
storage *storages.Storage,
) error {
if database.Type != databases.DatabaseTypePostgres {
if originalDB.Type != databases.DatabaseTypePostgres {
return errors.New("database type not supported")
}
@@ -54,7 +55,7 @@ func (uc *RestorePostgresqlBackupUsecase) Execute(
backup.ID,
)
pg := restore.Postgresql
pg := restoringToDB.Postgresql
if pg == nil {
return fmt.Errorf("postgresql configuration is required for restore")
}
@@ -78,11 +79,12 @@ func (uc *RestorePostgresqlBackupUsecase) Execute(
"--verbose", // Add verbose output to help with debugging
"--clean", // Clean (drop) database objects before recreating them
"--if-exists", // Use IF EXISTS when dropping objects
"--no-owner",
"--no-owner", // Skip restoring ownership
"--no-acl", // Skip restoring access privileges (GRANT/REVOKE commands)
}
return uc.restoreFromStorage(
database,
originalDB,
tools.GetPostgresqlExecutable(
pg.Version,
"pg_restore",
@@ -232,7 +234,7 @@ func (uc *RestorePostgresqlBackupUsecase) downloadBackupToTempFile(
}
// Get master key
masterKey, err := uc.secretKeyRepo.GetSecretKey()
masterKey, err := uc.secretKeyService.GetSecretKey()
if err != nil {
cleanupFunc()
return "", nil, fmt.Errorf("failed to get master key for decryption: %w", err)

View File

@@ -17,13 +17,15 @@ type RestoreBackupUsecase struct {
func (uc *RestoreBackupUsecase) Execute(
backupConfig *backups_config.BackupConfig,
restore models.Restore,
database *databases.Database,
originalDB *databases.Database,
restoringToDB *databases.Database,
backup *backups.Backup,
storage *storages.Storage,
) error {
if database.Type == databases.DatabaseTypePostgres {
if originalDB.Type == databases.DatabaseTypePostgres {
return uc.restorePostgresqlBackupUsecase.Execute(
database,
originalDB,
restoringToDB,
backupConfig,
restore,
backup,

View File

@@ -1,13 +1,8 @@
package users_repositories
var secretKeyRepository = &SecretKeyRepository{}
var userRepository = &UserRepository{}
var usersSettingsRepository = &UsersSettingsRepository{}
func GetSecretKeyRepository() *SecretKeyRepository {
return secretKeyRepository
}
func GetUserRepository() *UserRepository {
return userRepository
}

View File

@@ -1,34 +0,0 @@
package users_repositories
import (
"errors"
user_models "postgresus-backend/internal/features/users/models"
"postgresus-backend/internal/storage"
"github.com/google/uuid"
"gorm.io/gorm"
)
type SecretKeyRepository struct{}
func (r *SecretKeyRepository) GetSecretKey() (string, error) {
var secretKey user_models.SecretKey
if err := storage.GetDb().First(&secretKey).Error; err != nil {
// create a new secret key if not found
if errors.Is(err, gorm.ErrRecordNotFound) {
newSecretKey := user_models.SecretKey{
Secret: uuid.New().String() + uuid.New().String(),
}
if err := storage.GetDb().Create(&newSecretKey).Error; err != nil {
return "", errors.New("failed to create new secret key")
}
return newSecretKey.Secret, nil
}
return "", err
}
return secretKey.Secret, nil
}

View File

@@ -1,10 +1,13 @@
package users_services
import users_repositories "postgresus-backend/internal/features/users/repositories"
import (
"postgresus-backend/internal/features/encryption/secrets"
users_repositories "postgresus-backend/internal/features/users/repositories"
)
var userService = &UserService{
users_repositories.GetUserRepository(),
users_repositories.GetSecretKeyRepository(),
secrets.GetSecretKeyService(),
settingsService,
nil,
}

View File

@@ -17,6 +17,7 @@ import (
"golang.org/x/oauth2/google"
"postgresus-backend/internal/config"
"postgresus-backend/internal/features/encryption/secrets"
users_dto "postgresus-backend/internal/features/users/dto"
users_enums "postgresus-backend/internal/features/users/enums"
users_interfaces "postgresus-backend/internal/features/users/interfaces"
@@ -25,10 +26,10 @@ import (
)
type UserService struct {
userRepository *users_repositories.UserRepository
secretKeyRepository *users_repositories.SecretKeyRepository
settingsService *SettingsService
auditLogWriter users_interfaces.AuditLogWriter
userRepository *users_repositories.UserRepository
secretKeyService *secrets.SecretKeyService
settingsService *SettingsService
auditLogWriter users_interfaces.AuditLogWriter
}
func (s *UserService) SetAuditLogWriter(writer users_interfaces.AuditLogWriter) {
@@ -162,7 +163,7 @@ func (s *UserService) SignIn(
}
func (s *UserService) GetUserFromToken(token string) (*users_models.User, error) {
secretKey, err := s.secretKeyRepository.GetSecretKey()
secretKey, err := s.secretKeyService.GetSecretKey()
if err != nil {
return nil, fmt.Errorf("failed to get secret key: %w", err)
}
@@ -221,7 +222,7 @@ func (s *UserService) GetUserFromToken(token string) (*users_models.User, error)
func (s *UserService) GenerateAccessToken(
user *users_models.User,
) (*users_dto.SignInResponseDTO, error) {
secretKey, err := s.secretKeyRepository.GetSecretKey()
secretKey, err := s.secretKeyService.GetSecretKey()
if err != nil {
return nil, fmt.Errorf("failed to get secret key: %w", err)
}

View File

@@ -1,9 +1,9 @@
package encryption
import users_repositories "postgresus-backend/internal/features/users/repositories"
import "postgresus-backend/internal/features/encryption/secrets"
var fieldEncryptor = &SecretKeyFieldEncryptor{
users_repositories.GetSecretKeyRepository(),
secrets.GetSecretKeyService(),
}
func GetFieldEncryptor() FieldEncryptor {

View File

@@ -8,17 +8,16 @@ import (
"encoding/base64"
"errors"
"fmt"
"postgresus-backend/internal/features/encryption/secrets"
"strings"
users_repositories "postgresus-backend/internal/features/users/repositories"
"github.com/google/uuid"
)
const encryptedPrefix = "enc:"
type SecretKeyFieldEncryptor struct {
secretKeyRepository *users_repositories.SecretKeyRepository
secretKeyService *secrets.SecretKeyService
}
func (e *SecretKeyFieldEncryptor) Encrypt(itemID uuid.UUID, plaintext string) (string, error) {
@@ -30,7 +29,7 @@ func (e *SecretKeyFieldEncryptor) Encrypt(itemID uuid.UUID, plaintext string) (s
return plaintext, nil
}
masterKey, err := e.secretKeyRepository.GetSecretKey()
masterKey, err := e.secretKeyService.GetSecretKey()
if err != nil {
return "", fmt.Errorf("failed to get master key: %w", err)
}
@@ -82,7 +81,7 @@ func (e *SecretKeyFieldEncryptor) Decrypt(itemID uuid.UUID, ciphertext string) (
return "", fmt.Errorf("failed to decode ciphertext: %w", err)
}
masterKey, err := e.secretKeyRepository.GetSecretKey()
masterKey, err := e.secretKeyService.GetSecretKey()
if err != nil {
return "", fmt.Errorf("failed to get master key: %w", err)
}

View File

@@ -0,0 +1,28 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE postgresql_databases
DROP CONSTRAINT IF EXISTS fk_postgresql_databases_restore_id;
DROP INDEX IF EXISTS idx_postgresql_databases_restore_id;
ALTER TABLE postgresql_databases
DROP COLUMN IF EXISTS restore_id;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE postgresql_databases
ADD COLUMN restore_id UUID;
CREATE INDEX idx_postgresql_databases_restore_id ON postgresql_databases (restore_id);
ALTER TABLE postgresql_databases
ADD CONSTRAINT fk_postgresql_databases_restore_id
FOREIGN KEY (restore_id)
REFERENCES restores (id)
ON DELETE CASCADE;
-- +goose StatementEnd

View File

@@ -1,7 +1,9 @@
import { getApplicationServer } from '../../../constants';
import RequestOptions from '../../../shared/api/RequestOptions';
import { apiHelper } from '../../../shared/api/apiHelper';
import type { CreateReadOnlyUserResponse } from '../model/CreateReadOnlyUserResponse';
import type { Database } from '../model/Database';
import type { IsReadOnlyResponse } from '../model/IsReadOnlyResponse';
export const databaseApi = {
async createDatabase(database: Database) {
@@ -85,4 +87,22 @@ export const databaseApi = {
)
.then((res) => res.isUsing);
},
async isUserReadOnly(database: Database) {
const requestOptions: RequestOptions = new RequestOptions();
requestOptions.setBody(JSON.stringify(database));
return apiHelper.fetchPostJson<IsReadOnlyResponse>(
`${getApplicationServer()}/api/v1/databases/is-readonly`,
requestOptions,
);
},
async createReadOnlyUser(database: Database) {
const requestOptions: RequestOptions = new RequestOptions();
requestOptions.setBody(JSON.stringify(database));
return apiHelper.fetchPostJson<CreateReadOnlyUserResponse>(
`${getApplicationServer()}/api/v1/databases/create-readonly-user`,
requestOptions,
);
},
};

View File

@@ -4,3 +4,5 @@ export { DatabaseType } from './model/DatabaseType';
export { Period } from './model/Period';
export { type PostgresqlDatabase } from './model/postgresql/PostgresqlDatabase';
export { PostgresqlVersion } from './model/postgresql/PostgresqlVersion';
export { type IsReadOnlyResponse } from './model/IsReadOnlyResponse';
export { type CreateReadOnlyUserResponse } from './model/CreateReadOnlyUserResponse';

View File

@@ -0,0 +1,4 @@
export interface CreateReadOnlyUserResponse {
username: string;
password: string;
}

View File

@@ -0,0 +1,3 @@
export interface IsReadOnlyResponse {
isReadOnly: boolean;
}

View File

@@ -9,6 +9,7 @@ import {
databaseApi,
} from '../../../entity/databases';
import { EditBackupConfigComponent } from '../../backups';
import { CreateReadOnlyComponent } from './edit/CreateReadOnlyComponent';
import { EditDatabaseBaseInfoComponent } from './edit/EditDatabaseBaseInfoComponent';
import { EditDatabaseNotifiersComponent } from './edit/EditDatabaseNotifiersComponent';
import { EditDatabaseSpecificDataComponent } from './edit/EditDatabaseSpecificDataComponent';
@@ -41,9 +42,9 @@ export const CreateDatabaseComponent = ({ workspaceId, onCreated, onClose }: Pro
sendNotificationsOn: [],
} as Database);
const [step, setStep] = useState<'base-info' | 'db-settings' | 'backup-config' | 'notifiers'>(
'base-info',
);
const [step, setStep] = useState<
'base-info' | 'db-settings' | 'create-readonly-user' | 'backup-config' | 'notifiers'
>('base-info');
const createDatabase = async (database: Database, backupConfig: BackupConfig) => {
setIsCreating(true);
@@ -97,12 +98,25 @@ export const CreateDatabaseComponent = ({ workspaceId, onCreated, onClose }: Pro
isSaveToApi={false}
onSaved={(database) => {
setDatabase({ ...database });
setStep('backup-config');
setStep('create-readonly-user');
}}
/>
);
}
if (step === 'create-readonly-user') {
return (
<CreateReadOnlyComponent
database={database}
onReadOnlyUserUpdated={(database) => {
setDatabase({ ...database });
}}
onGoBack={() => setStep('db-settings')}
onContinue={() => setStep('backup-config')}
/>
);
}
if (step === 'backup-config') {
return (
<EditBackupConfigComponent

View File

@@ -0,0 +1,165 @@
import { Button, Modal, Spin } from 'antd';
import { useEffect, useState } from 'react';
import { type Database, databaseApi } from '../../../../entity/databases';
interface Props {
database: Database;
onReadOnlyUserUpdated: (database: Database) => void;
onGoBack: () => void;
onContinue: () => void;
}
export const CreateReadOnlyComponent = ({
database,
onReadOnlyUserUpdated,
onGoBack,
onContinue,
}: Props) => {
const [isCheckingReadOnlyUser, setIsCheckingReadOnlyUser] = useState(false);
const [isCreatingReadOnlyUser, setIsCreatingReadOnlyUser] = useState(false);
const [isShowSkipConfirmation, setShowSkipConfirmation] = useState(false);
const checkReadOnlyUser = async (): Promise<boolean> => {
try {
const response = await databaseApi.isUserReadOnly(database);
return response.isReadOnly;
} catch (e) {
alert((e as Error).message);
return false;
}
};
const createReadOnlyUser = async () => {
setIsCreatingReadOnlyUser(true);
try {
const response = await databaseApi.createReadOnlyUser(database);
database.postgresql!.username = response.username;
database.postgresql!.password = response.password;
onReadOnlyUserUpdated(database);
onContinue();
} catch (e) {
alert((e as Error).message);
}
setIsCreatingReadOnlyUser(false);
};
const handleSkip = () => {
setShowSkipConfirmation(true);
};
const handleSkipConfirmed = () => {
setShowSkipConfirmation(false);
onContinue();
};
useEffect(() => {
const run = async () => {
setIsCheckingReadOnlyUser(true);
const isReadOnly = await checkReadOnlyUser();
if (isReadOnly) {
// already has a read-only user
onContinue();
}
setIsCheckingReadOnlyUser(false);
};
run();
}, []);
if (isCheckingReadOnlyUser) {
return (
<div className="flex items-center">
<Spin />
<span className="ml-3">Checking read-only user...</span>
</div>
);
}
return (
<div>
<div className="mb-5">
<p className="mb-3 text-lg font-bold">Create a read-only user for Postgresus?</p>
<p className="mb-2">
A read-only user is a PostgreSQL user with limited permissions that can only read data
from your database, not modify it. This is recommended for backup operations because:
</p>
<ul className="mb-2 ml-5 list-disc">
<li>it prevents accidental data modifications during backup</li>
<li>it follows the principle of least privilege</li>
<li>it&apos;s a security best practice</li>
</ul>
<p className="mb-2">
Postgresus enforce enterprise-grade security (
<a href="https://postgresus.com/security" target="_blank" rel="noreferrer">
read in details here
</a>
). However, it is not possible to be covered from all possible risks.
</p>
<p className="mt-3">
<b>Read-only user allows to avoid storing credentials with write access at all</b>. Even
in the worst case of hacking, nobody will be able to corrupt your data.
</p>
</div>
<div className="mt-5 flex">
<Button className="mr-auto" type="primary" ghost onClick={() => onGoBack()}>
Back
</Button>
<Button className="mr-2 ml-auto" danger ghost onClick={handleSkip}>
Skip
</Button>
<Button
type="primary"
onClick={createReadOnlyUser}
loading={isCreatingReadOnlyUser}
disabled={isCreatingReadOnlyUser}
>
Yes, create read-only user
</Button>
</div>
<Modal
title="Skip read-only user creation?"
open={isShowSkipConfirmation}
onCancel={() => setShowSkipConfirmation(false)}
footer={null}
width={450}
>
<div className="mb-5">
<p className="mb-2">Are you sure you want to skip creating a read-only user?</p>
<p className="mb-2">
Using a user with full permissions for backups is not recommended and may pose security
risks. Postgresus is highly recommending you to not skip this step.
</p>
<p>
100% protection is never possible. It&apos;s better to be safe in case of 0.01% risk of
full hacking. So it is better to follow the secure way with read-only user.
</p>
</div>
<div className="flex justify-end">
<Button className="mr-2" danger onClick={handleSkipConfirmed}>
Yes, I accept risks
</Button>
<Button type="primary" onClick={() => setShowSkipConfirmation(false)}>
Let&apos;s continue with the secure way
</Button>
</div>
</Modal>
</div>
);
};

View File

@@ -1,6 +1,6 @@
import { ExclamationCircleOutlined, SyncOutlined } from '@ant-design/icons';
import { CopyOutlined, ExclamationCircleOutlined, SyncOutlined } from '@ant-design/icons';
import { CheckCircleOutlined } from '@ant-design/icons';
import { Button, Modal, Spin, Tooltip } from 'antd';
import { App, Button, Modal, Spin, Tooltip } from 'antd';
import dayjs from 'dayjs';
import { useEffect, useRef, useState } from 'react';
@@ -16,11 +16,14 @@ interface Props {
}
export const RestoresComponent = ({ database, backup }: Props) => {
const { message } = App.useApp();
const [editingDatabase, setEditingDatabase] = useState<Database>({
...database,
postgresql: database.postgresql
? ({
...database.postgresql,
username: undefined,
host: undefined,
port: undefined,
password: undefined,
@@ -231,9 +234,21 @@ export const RestoresComponent = ({ database, backup }: Props) => {
title="Restore error details"
open={!!showingRestoreError}
onCancel={() => setShowingRestoreError(undefined)}
footer={null}
footer={
<Button
icon={<CopyOutlined />}
onClick={() => {
navigator.clipboard.writeText(showingRestoreError.failMessage || '');
message.success('Error message copied to clipboard');
}}
>
Copy
</Button>
}
>
<div className="text-sm">{showingRestoreError.failMessage}</div>
<div className="overflow-y-auto text-sm whitespace-pre-wrap" style={{ height: '400px' }}>
{showingRestoreError.failMessage}
</div>
</Modal>
)}
</div>