Compare commits

...

30 Commits

Author SHA1 Message Date
Rostislav Dugin
7007236f2f FIX (email): Recrate client in case of auth error 2025-12-02 09:43:49 +03:00
Rostislav Dugin
db55cad310 Merge pull request #116 from RostislavDugin/feature/helm_chart
FIX (helm): Add git clone step
2025-12-02 00:02:13 +03:00
Rostislav Dugin
25bd096c81 FIX (helm): Add git clone step 2025-12-01 23:57:05 +03:00
Rostislav Dugin
7e98dd578c Merge pull request #115 from RostislavDugin/feature/helm_chart
Feature/helm chart
2025-12-01 23:47:27 +03:00
Rostislav Dugin
ba37b30e83 FEATURE (helm): Add Helm chart installation 2025-12-01 23:47:00 +03:00
Rostislav Dugin
34b3f822e3 Merge pull request #114 from spa-skyson/helmchart
helmchart v1.0.0
2025-12-01 23:18:20 +03:00
Rostislav Dugin
14700130b7 FIX (email): Add login auth in case if plain fails 2025-12-01 23:16:54 +03:00
Alexander Gazal
de11ab8d8a helmchart v1.0.0 2025-12-01 08:47:17 +03:00
Rostislav Dugin
06282bb435 FIX (connection): Avoid usage of prepare statements to get rid of problem with PgBounder 2025-11-30 20:50:25 +03:00
Rostislav Dugin
a3b263bbac FIX (installation): Fix installation on Debian 2025-11-30 20:25:28 +03:00
Rostislav Dugin
a956dccf7c FIX (whitelist): Show hint about Postgresus whitelist in case of connection failure 2025-11-28 23:59:20 +03:00
Rostislav Dugin
ce9fa18d58 FEATURE (webhook): Add webhook customization 2025-11-28 21:53:44 +03:00
Rostislav Dugin
281e185f21 FIX (dark): Add dark theme image 2025-11-27 23:17:43 +03:00
Rostislav Dugin
bb5b0064ea Merge branch 'main' of https://github.com/RostislavDugin/postgresus 2025-11-27 22:19:34 +03:00
Rostislav Dugin
da95bbb178 FIX (s3): Do not allow to change prefix after creation 2025-11-27 22:00:21 +03:00
Rostislav Dugin
cfe5993831 Merge pull request #110 from RostislavDugin/feature/pgpass_escape
Feature/pgpass escape
2025-11-27 17:03:06 +03:00
Rostislav Dugin
fa0e3d1ce2 REFACTOR (pgpass): Refactor escaping 2025-11-27 17:00:26 +03:00
Rostislav Dugin
d07085c462 Merge pull request #108 from kapawit/fix/pgpass-special-characters
FIX (postgresql): Escape special characters in .pgpass file for authentication
2025-11-27 16:54:38 +03:00
kapawit
c89c1f9654 FIX (postgresql): Escape special characters in .pgpass file for authentication 2025-11-26 21:35:38 +07:00
Rostislav Dugin
6cfc0ca79b FEATURE (dark): Add dark theme 2025-11-26 00:07:23 +03:00
Rostislav Dugin
5d27123bd7 FEATURE (adaptivity): Add mobile adaptivity 2025-11-25 21:40:46 +03:00
Rostislav Dugin
79ca374bb6 FEATURE (notifiers): Add mobile adaptivity for notifiers 2025-11-23 23:43:58 +03:00
Rostislav Dugin
b3f1a6f7e5 FEATURE (databases): Add adaptivity for mobile databases 2025-11-23 20:23:05 +03:00
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
Rostislav Dugin
95c833b619 FIX (backups): Fix passing encypted password to .pgpass 2025-11-19 17:10:19 +03:00
Rostislav Dugin
878fad5747 FEATURE (encryption): Add encyption for secrets in notifiers and storages 2025-11-18 21:23:59 +03:00
Rostislav Dugin
6ff3096695 FIX (password reset): Allow to change user password even if password was not set before 2025-11-17 20:20:31 +03:00
148 changed files with 7121 additions and 2492 deletions

3
.gitignore vendored
View File

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

View File

@@ -25,6 +25,8 @@
<a href="https://postgresus.com" target="_blank"><strong>🌐 Postgresus website</strong></a>
</p>
<img src="assets/dashboard-dark.svg" alt="Postgresus Dark Dashboard" width="800" style="margin-bottom: 10px;"/>
<img src="assets/dashboard.svg" alt="Postgresus Dashboard" width="800"/>
@@ -58,12 +60,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>
@@ -72,6 +74,12 @@
- **Audit logs**: Track all system activities and changes made by users
- **User roles**: Assign viewer, member, admin or owner roles within workspaces
### 🎨 **UX-Friendly**
- **Designer-polished UI**: Clean, intuitive interface crafted with attention to detail
- **Dark & light themes**: Choose the look that suits your workflow
- **Mobile adaptive**: Check your backups from anywhere on any device
### 🐳 **Self-Hosted & Secure**
- **Docker-based**: Easy deployment and management
@@ -149,6 +157,49 @@ Then run:
docker compose up -d
```
### Option 4: Kubernetes with Helm
For Kubernetes deployments, use the official Helm chart.
**Step 1:** Clone the repository:
```bash
git clone https://github.com/RostislavDugin/postgresus.git
cd postgresus
```
**Step 2:** Install with Helm:
```bash
helm install postgresus ./deploy/postgresus -n postgresus --create-namespace
```
To customize the installation, create a `values.yaml` file:
```yaml
ingress:
hosts:
- host: backup.yourdomain.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: backup-yourdomain-com-tls
hosts:
- backup.yourdomain.com
persistence:
size: 20Gi
```
Then install with your custom values:
```bash
helm install postgresus ./deploy/postgresus -n postgresus --create-namespace -f values.yaml
```
See the [Helm chart README](deploy/postgresus/README.md) for all configuration options.
---
## 🚀 Usage

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 537 KiB

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

@@ -5,6 +5,7 @@ import (
"postgresus-backend/internal/config"
backups_config "postgresus-backend/internal/features/backups/config"
"postgresus-backend/internal/features/storages"
"postgresus-backend/internal/util/encryption"
"postgresus-backend/internal/util/period"
"time"
)
@@ -131,7 +132,8 @@ func (s *BackupBackgroundService) cleanOldBackups() error {
continue
}
err = storage.DeleteFile(backup.ID)
encryptor := encryption.GetFieldEncryptor()
err = storage.DeleteFile(encryptor, backup.ID)
if err != nil {
s.logger.Error("Failed to delete backup file", "backupId", backup.ID, "error", err)
}

View File

@@ -26,6 +26,7 @@ import (
users_testing "postgresus-backend/internal/features/users/testing"
workspaces_models "postgresus-backend/internal/features/workspaces/models"
workspaces_testing "postgresus-backend/internal/features/workspaces/testing"
"postgresus-backend/internal/util/encryption"
test_utils "postgresus-backend/internal/util/testing"
"postgresus-backend/internal/util/tools"
)
@@ -700,7 +701,7 @@ func createTestBackup(
dummyContent := []byte("dummy backup content for testing")
reader := strings.NewReader(string(dummyContent))
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if err := storages[0].SaveFile(logger, backup.ID, reader); err != nil {
if err := storages[0].SaveFile(encryption.GetFieldEncryptor(), logger, backup.ID, reader); err != nil {
panic(fmt.Sprintf("Failed to create test backup file: %v", err))
}

View File

@@ -1,16 +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{}
@@ -24,7 +26,8 @@ var backupService = &BackupService{
notifiers.GetNotifierService(),
notifiers.GetNotifierService(),
backups_config.GetBackupConfigService(),
users_repositories.GetSecretKeyRepository(),
encryption_secrets.GetSecretKeyService(),
encryption.GetFieldEncryptor(),
usecases.GetCreateBackupUsecase(),
logger.GetLogger(),
[]BackupRemoveListener{},

View File

@@ -7,18 +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"
"slices"
"strings"
"time"
util_encryption "postgresus-backend/internal/util/encryption"
"github.com/google/uuid"
)
@@ -30,7 +32,8 @@ 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
@@ -284,7 +287,7 @@ func (s *BackupService) MakeBackup(databaseID uuid.UUID, isLastTry bool) {
// Delete partial backup from storage
storage, storageErr := s.storageService.GetStorageByID(backup.StorageID)
if storageErr == nil {
if deleteErr := storage.DeleteFile(backup.ID); deleteErr != nil {
if deleteErr := storage.DeleteFile(s.fieldEncryptor, backup.ID); deleteErr != nil {
s.logger.Error(
"Failed to delete partial backup file",
"backupId",
@@ -545,7 +548,7 @@ func (s *BackupService) deleteBackup(backup *Backup) error {
return err
}
err = storage.DeleteFile(backup.ID)
err = storage.DeleteFile(s.fieldEncryptor, backup.ID)
if err != nil {
// we do not return error here, because sometimes clean up performed
// before unavailable storage removal or change - therefore we should
@@ -599,7 +602,7 @@ func (s *BackupService) getBackupReader(backupID uuid.UUID) (io.ReadCloser, erro
return nil, fmt.Errorf("failed to get storage: %w", err)
}
fileReader, err := storage.GetFile(backup.ID)
fileReader, err := storage.GetFile(s.fieldEncryptor, backup.ID)
if err != nil {
return nil, fmt.Errorf("failed to get backup file: %w", err)
}
@@ -626,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,23 @@ package backups
import (
"context"
"errors"
usecases_postgresql "postgresus-backend/internal/features/backups/backups/usecases/postgresql"
backups_config "postgresus-backend/internal/features/backups/config"
"postgresus-backend/internal/features/databases"
"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/logger"
"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_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"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
@@ -55,12 +57,13 @@ func Test_BackupExecuted_NotificationSent(t *testing.T) {
notifiers.GetNotifierService(),
mockNotificationSender,
backups_config.GetBackupConfigService(),
users_repositories.GetSecretKeyRepository(),
encryption_secrets.GetSecretKeyService(),
encryption.GetFieldEncryptor(),
&CreateFailedBackupUsecase{},
logger.GetLogger(),
[]BackupRemoveListener{},
workspaces_services.GetWorkspaceService(),
nil, // auditLogService
nil,
NewBackupContextManager(),
}
@@ -102,12 +105,13 @@ func Test_BackupExecuted_NotificationSent(t *testing.T) {
notifiers.GetNotifierService(),
mockNotificationSender,
backups_config.GetBackupConfigService(),
users_repositories.GetSecretKeyRepository(),
encryption_secrets.GetSecretKeyService(),
encryption.GetFieldEncryptor(),
&CreateSuccessBackupUsecase{},
logger.GetLogger(),
[]BackupRemoveListener{},
workspaces_services.GetWorkspaceService(),
nil, // auditLogService
nil,
NewBackupContextManager(),
}
@@ -126,12 +130,13 @@ func Test_BackupExecuted_NotificationSent(t *testing.T) {
notifiers.GetNotifierService(),
mockNotificationSender,
backups_config.GetBackupConfigService(),
users_repositories.GetSecretKeyRepository(),
encryption_secrets.GetSecretKeyService(),
encryption.GetFieldEncryptor(),
&CreateSuccessBackupUsecase{},
logger.GetLogger(),
[]BackupRemoveListener{},
workspaces_services.GetWorkspaceService(),
nil, // auditLogService
nil,
NewBackupContextManager(),
}

View File

@@ -15,12 +15,13 @@ import (
"time"
"postgresus-backend/internal/config"
"postgresus-backend/internal/features/backups/backups/encryption"
backup_encryption "postgresus-backend/internal/features/backups/backups/encryption"
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"
"github.com/google/uuid"
@@ -33,15 +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
logger *slog.Logger
secretKeyService *encryption_secrets.SecretKeyService
fieldEncryptor encryption.FieldEncryptor
}
// Execute creates a backup of the database
@@ -79,6 +80,11 @@ func (uc *CreatePostgresqlBackupUsecase) Execute(
args := uc.buildPgDumpArgs(pg)
decryptedPassword, err := uc.fieldEncryptor.Decrypt(db.ID, pg.Password)
if err != nil {
return nil, fmt.Errorf("failed to decrypt database password: %w", err)
}
return uc.streamToStorage(
ctx,
backupID,
@@ -90,7 +96,7 @@ func (uc *CreatePostgresqlBackupUsecase) Execute(
config.GetEnv().PostgresesInstallDir,
),
args,
pg.Password,
decryptedPassword,
storage,
db,
backupProgressListener,
@@ -166,7 +172,7 @@ func (uc *CreatePostgresqlBackupUsecase) streamToStorage(
// Start streaming into storage in its own goroutine
saveErrCh := make(chan error, 1)
go func() {
saveErr := storage.SaveFile(uc.logger, backupID, storageReader)
saveErr := storage.SaveFile(uc.fieldEncryptor, uc.logger, backupID, storageReader)
saveErrCh <- saveErr
}()
@@ -440,7 +446,7 @@ func (uc *CreatePostgresqlBackupUsecase) setupBackupEncryption(
backupID uuid.UUID,
backupConfig *backups_config.BackupConfig,
storageWriter io.WriteCloser,
) (io.Writer, *encryption.EncryptionWriter, BackupMetadata, error) {
) (io.Writer, *backup_encryption.EncryptionWriter, BackupMetadata, error) {
metadata := BackupMetadata{}
if backupConfig.Encryption != backups_config.BackupEncryptionEncrypted {
@@ -449,22 +455,22 @@ func (uc *CreatePostgresqlBackupUsecase) setupBackupEncryption(
return storageWriter, nil, metadata, nil
}
salt, err := encryption.GenerateSalt()
salt, err := backup_encryption.GenerateSalt()
if err != nil {
return nil, nil, metadata, fmt.Errorf("failed to generate salt: %w", err)
}
nonce, err := encryption.GenerateNonce()
nonce, err := backup_encryption.GenerateNonce()
if err != nil {
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)
}
encWriter, err := encryption.NewEncryptionWriter(
encWriter, err := backup_encryption.NewEncryptionWriter(
storageWriter,
masterKey,
backupID,
@@ -486,7 +492,7 @@ func (uc *CreatePostgresqlBackupUsecase) setupBackupEncryption(
}
func (uc *CreatePostgresqlBackupUsecase) cleanupOnCancellation(
encryptionWriter *encryption.EncryptionWriter,
encryptionWriter *backup_encryption.EncryptionWriter,
storageWriter io.WriteCloser,
saveErrCh chan error,
) {
@@ -510,7 +516,7 @@ func (uc *CreatePostgresqlBackupUsecase) cleanupOnCancellation(
}
func (uc *CreatePostgresqlBackupUsecase) closeWriters(
encryptionWriter *encryption.EncryptionWriter,
encryptionWriter *backup_encryption.EncryptionWriter,
storageWriter io.WriteCloser,
) error {
encryptionCloseErrCh := make(chan error, 1)
@@ -713,11 +719,15 @@ func (uc *CreatePostgresqlBackupUsecase) createTempPgpassFile(
return "", nil
}
escapedHost := tools.EscapePgpassField(pgConfig.Host)
escapedUsername := tools.EscapePgpassField(pgConfig.Username)
escapedPassword := tools.EscapePgpassField(password)
pgpassContent := fmt.Sprintf("%s:%d:*:%s:%s",
pgConfig.Host,
escapedHost,
pgConfig.Port,
pgConfig.Username,
password,
escapedUsername,
escapedPassword,
)
tempDir, err := os.MkdirTemp("", "pgpass")

View File

@@ -1,13 +1,15 @@
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(),
}
func GetCreatePostgresqlBackupUsecase() *CreatePostgresqlBackupUsecase {

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

@@ -16,6 +16,7 @@ import (
users_testing "postgresus-backend/internal/features/users/testing"
workspaces_controllers "postgresus-backend/internal/features/workspaces/controllers"
workspaces_testing "postgresus-backend/internal/features/workspaces/testing"
"postgresus-backend/internal/util/encryption"
test_utils "postgresus-backend/internal/util/testing"
"postgresus-backend/internal/util/tools"
)
@@ -769,6 +770,71 @@ func createTestDatabaseViaAPI(
return &database
}
func Test_CreateDatabase_PasswordIsEncryptedInDB(t *testing.T) {
router := createTestRouter()
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
testDbName := "test_db"
plainPassword := "my-super-secret-password-123"
request := Database{
Name: "Test Database",
WorkspaceID: &workspace.ID,
Type: DatabaseTypePostgres,
Postgresql: &postgresql.PostgresqlDatabase{
Version: tools.PostgresqlVersion16,
Host: "localhost",
Port: 5432,
Username: "postgres",
Password: plainPassword,
Database: &testDbName,
},
}
var createdDatabase Database
test_utils.MakePostRequestAndUnmarshal(
t,
router,
"/api/v1/databases/create",
"Bearer "+owner.Token,
request,
http.StatusCreated,
&createdDatabase,
)
repository := &DatabaseRepository{}
databaseFromDB, err := repository.FindByID(createdDatabase.ID)
assert.NoError(t, err)
assert.NotNil(t, databaseFromDB)
assert.NotNil(t, databaseFromDB.Postgresql)
assert.True(
t,
strings.HasPrefix(databaseFromDB.Postgresql.Password, "enc:"),
"Password should be encrypted in database with 'enc:' prefix, got: %s",
databaseFromDB.Postgresql.Password,
)
encryptor := encryption.GetFieldEncryptor()
decryptedPassword, err := encryptor.Decrypt(
databaseFromDB.ID,
databaseFromDB.Postgresql.Password,
)
assert.NoError(t, err)
assert.Equal(t, plainPassword, decryptedPassword,
"Decrypted password should match original plaintext password")
test_utils.MakeDeleteRequest(
t,
router,
"/api/v1/databases/"+createdDatabase.ID.String(),
"Bearer "+owner.Token,
http.StatusNoContent,
)
workspaces_testing.RemoveTestWorkspace(workspace, router)
}
func Test_DatabaseSensitiveDataLifecycle_AllTypes(t *testing.T) {
testCases := []struct {
name string
@@ -815,7 +881,15 @@ func Test_DatabaseSensitiveDataLifecycle_AllTypes(t *testing.T) {
}
},
verifySensitiveData: func(t *testing.T, database *Database) {
assert.Equal(t, "original-password-secret", database.Postgresql.Password)
// Verify password is encrypted
assert.True(t, strings.HasPrefix(database.Postgresql.Password, "enc:"),
"Password should be encrypted in database")
// Verify it can be decrypted back to original
encryptor := encryption.GetFieldEncryptor()
decrypted, err := encryptor.Decrypt(database.ID, database.Postgresql.Password)
assert.NoError(t, err)
assert.Equal(t, "original-password-secret", decrypted)
},
verifyHiddenData: func(t *testing.T, database *Database) {
assert.Equal(t, "", database.Postgresql.Password)

View File

@@ -5,9 +5,10 @@ import (
"errors"
"fmt"
"log/slog"
"postgresus-backend/internal/util/encryption"
"postgresus-backend/internal/util/tools"
"regexp"
"slices"
"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"`
@@ -59,11 +59,15 @@ func (p *PostgresqlDatabase) Validate() error {
return nil
}
func (p *PostgresqlDatabase) TestConnection(logger *slog.Logger) error {
func (p *PostgresqlDatabase) TestConnection(
logger *slog.Logger,
encryptor encryption.FieldEncryptor,
databaseID uuid.UUID,
) error {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
return testSingleDatabaseConnection(logger, ctx, p)
return testSingleDatabaseConnection(logger, ctx, p, encryptor, databaseID)
}
func (p *PostgresqlDatabase) HideSensitiveData() {
@@ -87,19 +91,420 @@ func (p *PostgresqlDatabase) Update(incoming *PostgresqlDatabase) {
}
}
func (p *PostgresqlDatabase) EncryptSensitiveFields(
databaseID uuid.UUID,
encryptor encryption.FieldEncryptor,
) error {
if p.Password != "" {
encrypted, err := encryptor.Encrypt(databaseID, p.Password)
if err != nil {
return err
}
p.Password = encrypted
}
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,
ctx context.Context,
postgresDb *PostgresqlDatabase,
encryptor encryption.FieldEncryptor,
databaseID uuid.UUID,
) error {
// For single database backup, we need to connect to the specific database
if postgresDb.Database == nil || *postgresDb.Database == "" {
return errors.New("database name is required for single database backup (pg_dump)")
}
// Decrypt password if needed
password, err := decryptPasswordIfNeeded(postgresDb.Password, encryptor, databaseID)
if err != nil {
return fmt.Errorf("failed to decrypt password: %w", err)
}
// Build connection string for the specific database
connStr := buildConnectionStringForDB(postgresDb, *postgresDb.Database)
connStr := buildConnectionStringForDB(postgresDb, *postgresDb.Database, password)
// Test connection
conn, err := pgx.Connect(ctx, connStr)
@@ -182,116 +587,30 @@ func testBasicOperations(ctx context.Context, conn *pgx.Conn, dbName string) err
}
// buildConnectionStringForDB builds connection string for specific database
func buildConnectionStringForDB(p *PostgresqlDatabase, dbName string) string {
func buildConnectionStringForDB(p *PostgresqlDatabase, dbName string, password string) string {
sslMode := "disable"
if p.IsHttps {
sslMode = "require"
}
return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
return fmt.Sprintf(
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s default_query_exec_mode=simple_protocol",
p.Host,
p.Port,
p.Username,
p.Password,
password,
dbName,
sslMode,
)
}
func (p *PostgresqlDatabase) InstallExtensions(extensions []tools.PostgresqlExtension) error {
if len(extensions) == 0 {
return nil
func decryptPasswordIfNeeded(
password string,
encryptor encryption.FieldEncryptor,
databaseID uuid.UUID,
) (string, error) {
if encryptor == nil {
return password, nil
}
if p.Database == nil || *p.Database == "" {
return errors.New("database name is required for installing extensions")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Build connection string for the specific database
connStr := buildConnectionStringForDB(p, *p.Database)
// Connect to database
conn, err := pgx.Connect(ctx, connStr)
if err != nil {
return fmt.Errorf("failed to connect to database '%s': %w", *p.Database, err)
}
defer func() {
if closeErr := conn.Close(ctx); closeErr != nil {
fmt.Println("failed to close connection: %w", closeErr)
}
}()
// Check which extensions are already installed
installedExtensions, err := p.getInstalledExtensions(ctx, conn)
if err != nil {
return fmt.Errorf("failed to check installed extensions: %w", err)
}
// Install missing extensions
for _, extension := range extensions {
if contains(installedExtensions, string(extension)) {
continue // Extension already installed
}
if err := p.installExtension(ctx, conn, string(extension)); err != nil {
return fmt.Errorf("failed to install extension '%s': %w", extension, err)
}
}
return nil
}
// getInstalledExtensions queries the database for currently installed extensions
func (p *PostgresqlDatabase) getInstalledExtensions(
ctx context.Context,
conn *pgx.Conn,
) ([]string, error) {
query := "SELECT extname FROM pg_extension"
rows, err := conn.Query(ctx, query)
if err != nil {
return nil, fmt.Errorf("failed to query installed extensions: %w", err)
}
defer rows.Close()
var extensions []string
for rows.Next() {
var extname string
if err := rows.Scan(&extname); err != nil {
return nil, fmt.Errorf("failed to scan extension name: %w", err)
}
extensions = append(extensions, extname)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating over extension rows: %w", err)
}
return extensions, nil
}
// installExtension installs a single PostgreSQL extension
func (p *PostgresqlDatabase) installExtension(
ctx context.Context,
conn *pgx.Conn,
extensionName string,
) error {
query := fmt.Sprintf("CREATE EXTENSION IF NOT EXISTS %s", extensionName)
_, err := conn.Exec(ctx, query)
if err != nil {
return fmt.Errorf("failed to execute CREATE EXTENSION: %w", err)
}
return nil
}
// contains checks if a string slice contains a specific string
func contains(slice []string, item string) bool {
return slices.Contains(slice, item)
return encryptor.Decrypt(databaseID, password)
}

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

@@ -5,6 +5,7 @@ import (
"postgresus-backend/internal/features/notifiers"
users_services "postgresus-backend/internal/features/users/services"
workspaces_services "postgresus-backend/internal/features/workspaces/services"
"postgresus-backend/internal/util/encryption"
"postgresus-backend/internal/util/logger"
)
@@ -19,6 +20,7 @@ var databaseService = &DatabaseService{
[]DatabaseCopyListener{},
workspaces_services.GetWorkspaceService(),
audit_logs.GetAuditLogService(),
encryption.GetFieldEncryptor(),
}
var databaseController = &DatabaseController{

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

@@ -2,6 +2,7 @@ package databases
import (
"log/slog"
"postgresus-backend/internal/util/encryption"
"github.com/google/uuid"
)
@@ -11,7 +12,11 @@ type DatabaseValidator interface {
}
type DatabaseConnector interface {
TestConnection(logger *slog.Logger) error
TestConnection(
logger *slog.Logger,
encryptor encryption.FieldEncryptor,
databaseID uuid.UUID,
) error
HideSensitiveData()
}

View File

@@ -5,6 +5,7 @@ import (
"log/slog"
"postgresus-backend/internal/features/databases/databases/postgresql"
"postgresus-backend/internal/features/notifiers"
"postgresus-backend/internal/util/encryption"
"time"
"github.com/google/uuid"
@@ -56,14 +57,24 @@ func (d *Database) ValidateUpdate(old, new Database) error {
return nil
}
func (d *Database) TestConnection(logger *slog.Logger) error {
return d.getSpecificDatabase().TestConnection(logger)
func (d *Database) TestConnection(
logger *slog.Logger,
encryptor encryption.FieldEncryptor,
) error {
return d.getSpecificDatabase().TestConnection(logger, encryptor, d.ID)
}
func (d *Database) HideSensitiveData() {
d.getSpecificDatabase().HideSensitiveData()
}
func (d *Database) EncryptSensitiveFields(encryptor encryption.FieldEncryptor) error {
if d.Postgresql != nil {
return d.Postgresql.EncryptSensitiveFields(d.ID, encryptor)
}
return nil
}
func (d *Database) Update(incoming *Database) {
d.Name = incoming.Name
d.Type = incoming.Type

View File

@@ -1,6 +1,7 @@
package databases
import (
"context"
"errors"
"fmt"
"log/slog"
@@ -11,6 +12,7 @@ import (
"postgresus-backend/internal/features/notifiers"
users_models "postgresus-backend/internal/features/users/models"
workspaces_services "postgresus-backend/internal/features/workspaces/services"
"postgresus-backend/internal/util/encryption"
"github.com/google/uuid"
)
@@ -26,6 +28,7 @@ type DatabaseService struct {
workspaceService *workspaces_services.WorkspaceService
auditLogService *audit_logs.AuditLogService
fieldEncryptor encryption.FieldEncryptor
}
func (s *DatabaseService) AddDbCreationListener(
@@ -65,6 +68,10 @@ func (s *DatabaseService) CreateDatabase(
return nil, err
}
if err := database.EncryptSensitiveFields(s.fieldEncryptor); err != nil {
return nil, fmt.Errorf("failed to encrypt sensitive fields: %w", err)
}
database, err = s.dbRepository.Save(database)
if err != nil {
return nil, err
@@ -118,6 +125,10 @@ func (s *DatabaseService) UpdateDatabase(
return err
}
if err := existingDatabase.EncryptSensitiveFields(s.fieldEncryptor); err != nil {
return fmt.Errorf("failed to encrypt sensitive fields: %w", err)
}
_, err = s.dbRepository.Save(existingDatabase)
if err != nil {
return err
@@ -250,7 +261,7 @@ func (s *DatabaseService) TestDatabaseConnection(
return errors.New("insufficient permissions to test connection for this database")
}
err = database.TestConnection(s.logger)
err = database.TestConnection(s.logger, s.fieldEncryptor)
if err != nil {
lastSaveError := err.Error()
database.LastBackupErrorMessage = &lastSaveError
@@ -294,7 +305,7 @@ func (s *DatabaseService) TestDatabaseConnectionDirect(
usingDatabase = database
}
return usingDatabase.TestConnection(s.logger)
return usingDatabase.TestConnection(s.logger, s.fieldEncryptor)
}
func (s *DatabaseService) GetDatabaseByID(
@@ -446,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

@@ -453,70 +453,6 @@ func Test_CrossWorkspaceSecurity_CannotAccessNotifierFromAnotherWorkspace(t *tes
workspaces_testing.RemoveTestWorkspace(workspace2, router)
}
func createRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
v1 := router.Group("/api/v1")
protected := v1.Group("").Use(users_middleware.AuthMiddleware(users_services.GetUserService()))
if routerGroup, ok := protected.(*gin.RouterGroup); ok {
GetNotifierController().RegisterRoutes(routerGroup)
workspaces_controllers.GetWorkspaceController().RegisterRoutes(routerGroup)
workspaces_controllers.GetMembershipController().RegisterRoutes(routerGroup)
}
audit_logs.SetupDependencies()
return router
}
func createNewNotifier(workspaceID uuid.UUID) *Notifier {
return &Notifier{
WorkspaceID: workspaceID,
Name: "Test Notifier " + uuid.New().String(),
NotifierType: NotifierTypeWebhook,
WebhookNotifier: &webhook_notifier.WebhookNotifier{
WebhookURL: "https://webhook.site/test-" + uuid.New().String(),
WebhookMethod: webhook_notifier.WebhookMethodPOST,
},
}
}
func createTelegramNotifier(workspaceID uuid.UUID) *Notifier {
env := config.GetEnv()
return &Notifier{
WorkspaceID: workspaceID,
Name: "Test Telegram Notifier " + uuid.New().String(),
NotifierType: NotifierTypeTelegram,
TelegramNotifier: &telegram_notifier.TelegramNotifier{
BotToken: env.TestTelegramBotToken,
TargetChatID: env.TestTelegramChatID,
},
}
}
func verifyNotifierData(t *testing.T, expected *Notifier, actual *Notifier) {
assert.Equal(t, expected.Name, actual.Name)
assert.Equal(t, expected.NotifierType, actual.NotifierType)
assert.Equal(t, expected.WorkspaceID, actual.WorkspaceID)
}
func deleteNotifier(
t *testing.T,
router *gin.Engine,
notifierID, workspaceID uuid.UUID,
token string,
) {
test_utils.MakeDeleteRequest(
t,
router,
fmt.Sprintf("/api/v1/notifiers/%s", notifierID.String()),
"Bearer "+token,
http.StatusOK,
)
}
func Test_NotifierSensitiveDataLifecycle_AllTypes(t *testing.T) {
testCases := []struct {
name string
@@ -553,7 +489,13 @@ func Test_NotifierSensitiveDataLifecycle_AllTypes(t *testing.T) {
}
},
verifySensitiveData: func(t *testing.T, notifier *Notifier) {
assert.Equal(t, "original-bot-token-12345", notifier.TelegramNotifier.BotToken)
assert.True(
t,
isEncrypted(notifier.TelegramNotifier.BotToken),
"BotToken should be encrypted in DB",
)
decrypted := decryptField(t, notifier.ID, notifier.TelegramNotifier.BotToken)
assert.Equal(t, "original-bot-token-12345", decrypted)
},
verifyHiddenData: func(t *testing.T, notifier *Notifier) {
assert.Equal(t, "", notifier.TelegramNotifier.BotToken)
@@ -592,7 +534,13 @@ func Test_NotifierSensitiveDataLifecycle_AllTypes(t *testing.T) {
}
},
verifySensitiveData: func(t *testing.T, notifier *Notifier) {
assert.Equal(t, "original-password-secret", notifier.EmailNotifier.SMTPPassword)
assert.True(
t,
isEncrypted(notifier.EmailNotifier.SMTPPassword),
"SMTPPassword should be encrypted in DB",
)
decrypted := decryptField(t, notifier.ID, notifier.EmailNotifier.SMTPPassword)
assert.Equal(t, "original-password-secret", decrypted)
},
verifyHiddenData: func(t *testing.T, notifier *Notifier) {
assert.Equal(t, "", notifier.EmailNotifier.SMTPPassword)
@@ -625,7 +573,13 @@ func Test_NotifierSensitiveDataLifecycle_AllTypes(t *testing.T) {
}
},
verifySensitiveData: func(t *testing.T, notifier *Notifier) {
assert.Equal(t, "xoxb-original-slack-token", notifier.SlackNotifier.BotToken)
assert.True(
t,
isEncrypted(notifier.SlackNotifier.BotToken),
"BotToken should be encrypted in DB",
)
decrypted := decryptField(t, notifier.ID, notifier.SlackNotifier.BotToken)
assert.Equal(t, "xoxb-original-slack-token", decrypted)
},
verifyHiddenData: func(t *testing.T, notifier *Notifier) {
assert.Equal(t, "", notifier.SlackNotifier.BotToken)
@@ -656,11 +610,17 @@ func Test_NotifierSensitiveDataLifecycle_AllTypes(t *testing.T) {
}
},
verifySensitiveData: func(t *testing.T, notifier *Notifier) {
assert.Equal(
assert.True(
t,
"https://discord.com/api/webhooks/123/original-token",
isEncrypted(notifier.DiscordNotifier.ChannelWebhookURL),
"WebhookURL should be encrypted in DB",
)
decrypted := decryptField(
t,
notifier.ID,
notifier.DiscordNotifier.ChannelWebhookURL,
)
assert.Equal(t, "https://discord.com/api/webhooks/123/original-token", decrypted)
},
verifyHiddenData: func(t *testing.T, notifier *Notifier) {
assert.Equal(t, "", notifier.DiscordNotifier.ChannelWebhookURL)
@@ -691,10 +651,16 @@ func Test_NotifierSensitiveDataLifecycle_AllTypes(t *testing.T) {
}
},
verifySensitiveData: func(t *testing.T, notifier *Notifier) {
assert.True(
t,
isEncrypted(notifier.TeamsNotifier.WebhookURL),
"WebhookURL should be encrypted in DB",
)
decrypted := decryptField(t, notifier.ID, notifier.TeamsNotifier.WebhookURL)
assert.Equal(
t,
"https://outlook.office.com/webhook/original-token",
notifier.TeamsNotifier.WebhookURL,
decrypted,
)
},
verifyHiddenData: func(t *testing.T, notifier *Notifier) {
@@ -813,3 +779,263 @@ func Test_NotifierSensitiveDataLifecycle_AllTypes(t *testing.T) {
})
}
}
func Test_CreateNotifier_AllSensitiveFieldsEncryptedInDB(t *testing.T) {
testCases := []struct {
name string
createNotifier func(workspaceID uuid.UUID) *Notifier
verifySensitiveEncryption func(t *testing.T, notifier *Notifier)
}{
{
name: "Telegram Notifier - BotToken encrypted",
createNotifier: func(workspaceID uuid.UUID) *Notifier {
return &Notifier{
WorkspaceID: workspaceID,
Name: "Test Telegram",
NotifierType: NotifierTypeTelegram,
TelegramNotifier: &telegram_notifier.TelegramNotifier{
BotToken: "plain-telegram-token-123",
TargetChatID: "123456789",
},
}
},
verifySensitiveEncryption: func(t *testing.T, notifier *Notifier) {
assert.True(
t,
isEncrypted(notifier.TelegramNotifier.BotToken),
"BotToken should be encrypted",
)
decrypted := decryptField(t, notifier.ID, notifier.TelegramNotifier.BotToken)
assert.Equal(t, "plain-telegram-token-123", decrypted)
},
},
{
name: "Email Notifier - SMTPPassword encrypted",
createNotifier: func(workspaceID uuid.UUID) *Notifier {
return &Notifier{
WorkspaceID: workspaceID,
Name: "Test Email",
NotifierType: NotifierTypeEmail,
EmailNotifier: &email_notifier.EmailNotifier{
TargetEmail: "test@example.com",
SMTPHost: "smtp.example.com",
SMTPPort: 587,
SMTPUser: "user@example.com",
SMTPPassword: "plain-smtp-password-456",
From: "noreply@example.com",
},
}
},
verifySensitiveEncryption: func(t *testing.T, notifier *Notifier) {
assert.True(
t,
isEncrypted(notifier.EmailNotifier.SMTPPassword),
"SMTPPassword should be encrypted",
)
decrypted := decryptField(t, notifier.ID, notifier.EmailNotifier.SMTPPassword)
assert.Equal(t, "plain-smtp-password-456", decrypted)
},
},
{
name: "Slack Notifier - BotToken encrypted",
createNotifier: func(workspaceID uuid.UUID) *Notifier {
return &Notifier{
WorkspaceID: workspaceID,
Name: "Test Slack",
NotifierType: NotifierTypeSlack,
SlackNotifier: &slack_notifier.SlackNotifier{
BotToken: "plain-slack-token-789",
TargetChatID: "C0123456789",
},
}
},
verifySensitiveEncryption: func(t *testing.T, notifier *Notifier) {
assert.True(
t,
isEncrypted(notifier.SlackNotifier.BotToken),
"BotToken should be encrypted",
)
decrypted := decryptField(t, notifier.ID, notifier.SlackNotifier.BotToken)
assert.Equal(t, "plain-slack-token-789", decrypted)
},
},
{
name: "Discord Notifier - WebhookURL encrypted",
createNotifier: func(workspaceID uuid.UUID) *Notifier {
return &Notifier{
WorkspaceID: workspaceID,
Name: "Test Discord",
NotifierType: NotifierTypeDiscord,
DiscordNotifier: &discord_notifier.DiscordNotifier{
ChannelWebhookURL: "https://discord.com/api/webhooks/123/abc",
},
}
},
verifySensitiveEncryption: func(t *testing.T, notifier *Notifier) {
assert.True(
t,
isEncrypted(notifier.DiscordNotifier.ChannelWebhookURL),
"WebhookURL should be encrypted",
)
decrypted := decryptField(
t,
notifier.ID,
notifier.DiscordNotifier.ChannelWebhookURL,
)
assert.Equal(t, "https://discord.com/api/webhooks/123/abc", decrypted)
},
},
{
name: "Teams Notifier - WebhookURL encrypted",
createNotifier: func(workspaceID uuid.UUID) *Notifier {
return &Notifier{
WorkspaceID: workspaceID,
Name: "Test Teams",
NotifierType: NotifierTypeTeams,
TeamsNotifier: &teams_notifier.TeamsNotifier{
WebhookURL: "https://outlook.office.com/webhook/test123",
},
}
},
verifySensitiveEncryption: func(t *testing.T, notifier *Notifier) {
assert.True(
t,
isEncrypted(notifier.TeamsNotifier.WebhookURL),
"WebhookURL should be encrypted",
)
decrypted := decryptField(t, notifier.ID, notifier.TeamsNotifier.WebhookURL)
assert.Equal(t, "https://outlook.office.com/webhook/test123", decrypted)
},
},
{
name: "Webhook Notifier - WebhookURL encrypted",
createNotifier: func(workspaceID uuid.UUID) *Notifier {
return &Notifier{
WorkspaceID: workspaceID,
Name: "Test Webhook",
NotifierType: NotifierTypeWebhook,
WebhookNotifier: &webhook_notifier.WebhookNotifier{
WebhookURL: "https://webhook.example.com/test456",
WebhookMethod: webhook_notifier.WebhookMethodPOST,
},
}
},
verifySensitiveEncryption: func(t *testing.T, notifier *Notifier) {
assert.True(
t,
isEncrypted(notifier.WebhookNotifier.WebhookURL),
"WebhookURL should be encrypted",
)
decrypted := decryptField(t, notifier.ID, notifier.WebhookNotifier.WebhookURL)
assert.Equal(t, "https://webhook.example.com/test456", decrypted)
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
router := createRouter()
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
// Create notifier via API (plaintext credentials)
var createdNotifier Notifier
test_utils.MakePostRequestAndUnmarshal(
t,
router,
"/api/v1/notifiers",
"Bearer "+owner.Token,
tc.createNotifier(workspace.ID),
http.StatusOK,
&createdNotifier,
)
// Read from DB directly (bypass service layer)
repository := &NotifierRepository{}
notifierFromDB, err := repository.FindByID(createdNotifier.ID)
assert.NoError(t, err)
// Verify encryption
tc.verifySensitiveEncryption(t, notifierFromDB)
// Cleanup
deleteNotifier(t, router, createdNotifier.ID, workspace.ID, owner.Token)
workspaces_testing.RemoveTestWorkspace(workspace, router)
})
}
}
func createRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
v1 := router.Group("/api/v1")
protected := v1.Group("").Use(users_middleware.AuthMiddleware(users_services.GetUserService()))
if routerGroup, ok := protected.(*gin.RouterGroup); ok {
GetNotifierController().RegisterRoutes(routerGroup)
workspaces_controllers.GetWorkspaceController().RegisterRoutes(routerGroup)
workspaces_controllers.GetMembershipController().RegisterRoutes(routerGroup)
}
audit_logs.SetupDependencies()
return router
}
func createNewNotifier(workspaceID uuid.UUID) *Notifier {
return &Notifier{
WorkspaceID: workspaceID,
Name: "Test Notifier " + uuid.New().String(),
NotifierType: NotifierTypeWebhook,
WebhookNotifier: &webhook_notifier.WebhookNotifier{
WebhookURL: "https://webhook.site/test-" + uuid.New().String(),
WebhookMethod: webhook_notifier.WebhookMethodPOST,
},
}
}
func createTelegramNotifier(workspaceID uuid.UUID) *Notifier {
env := config.GetEnv()
return &Notifier{
WorkspaceID: workspaceID,
Name: "Test Telegram Notifier " + uuid.New().String(),
NotifierType: NotifierTypeTelegram,
TelegramNotifier: &telegram_notifier.TelegramNotifier{
BotToken: env.TestTelegramBotToken,
TargetChatID: env.TestTelegramChatID,
},
}
}
func verifyNotifierData(t *testing.T, expected *Notifier, actual *Notifier) {
assert.Equal(t, expected.Name, actual.Name)
assert.Equal(t, expected.NotifierType, actual.NotifierType)
assert.Equal(t, expected.WorkspaceID, actual.WorkspaceID)
}
func deleteNotifier(
t *testing.T,
router *gin.Engine,
notifierID, workspaceID uuid.UUID,
token string,
) {
test_utils.MakeDeleteRequest(
t,
router,
fmt.Sprintf("/api/v1/notifiers/%s", notifierID.String()),
"Bearer "+token,
http.StatusOK,
)
}
func isEncrypted(value string) bool {
return len(value) > 4 && value[:4] == "enc:"
}
func decryptField(t *testing.T, notifierID uuid.UUID, encryptedValue string) string {
encryptor := GetNotifierService().fieldEncryptor
decrypted, err := encryptor.Decrypt(notifierID, encryptedValue)
assert.NoError(t, err)
return decrypted
}

View File

@@ -3,6 +3,7 @@ package notifiers
import (
audit_logs "postgresus-backend/internal/features/audit_logs"
workspaces_services "postgresus-backend/internal/features/workspaces/services"
"postgresus-backend/internal/util/encryption"
"postgresus-backend/internal/util/logger"
)
@@ -12,6 +13,7 @@ var notifierService = &NotifierService{
logger.GetLogger(),
workspaces_services.GetWorkspaceService(),
audit_logs.GetAuditLogService(),
encryption.GetFieldEncryptor(),
}
var notifierController = &NotifierController{
notifierService,

View File

@@ -1,11 +1,21 @@
package notifiers
import "log/slog"
import (
"log/slog"
"postgresus-backend/internal/util/encryption"
)
type NotificationSender interface {
Send(logger *slog.Logger, heading string, message string) error
Send(
encryptor encryption.FieldEncryptor,
logger *slog.Logger,
heading string,
message string,
) error
Validate() error
Validate(encryptor encryption.FieldEncryptor) error
HideSensitiveData()
EncryptSensitiveData(encryptor encryption.FieldEncryptor) error
}

View File

@@ -9,6 +9,7 @@ import (
teams_notifier "postgresus-backend/internal/features/notifiers/models/teams"
telegram_notifier "postgresus-backend/internal/features/notifiers/models/telegram"
webhook_notifier "postgresus-backend/internal/features/notifiers/models/webhook"
"postgresus-backend/internal/util/encryption"
"github.com/google/uuid"
)
@@ -33,16 +34,21 @@ func (n *Notifier) TableName() string {
return "notifiers"
}
func (n *Notifier) Validate() error {
func (n *Notifier) Validate(encryptor encryption.FieldEncryptor) error {
if n.Name == "" {
return errors.New("name is required")
}
return n.getSpecificNotifier().Validate()
return n.getSpecificNotifier().Validate(encryptor)
}
func (n *Notifier) Send(logger *slog.Logger, heading string, message string) error {
err := n.getSpecificNotifier().Send(logger, heading, message)
func (n *Notifier) Send(
encryptor encryption.FieldEncryptor,
logger *slog.Logger,
heading string,
message string,
) error {
err := n.getSpecificNotifier().Send(encryptor, logger, heading, message)
if err != nil {
lastSendError := err.Error()
@@ -58,6 +64,10 @@ func (n *Notifier) HideSensitiveData() {
n.getSpecificNotifier().HideSensitiveData()
}
func (n *Notifier) EncryptSensitiveData(encryptor encryption.FieldEncryptor) error {
return n.getSpecificNotifier().EncryptSensitiveData(encryptor)
}
func (n *Notifier) Update(incoming *Notifier) {
n.Name = incoming.Name
n.NotifierType = incoming.NotifierType

View File

@@ -8,6 +8,7 @@ import (
"io"
"log/slog"
"net/http"
"postgresus-backend/internal/util/encryption"
"github.com/google/uuid"
)
@@ -21,7 +22,7 @@ func (d *DiscordNotifier) TableName() string {
return "discord_notifiers"
}
func (d *DiscordNotifier) Validate() error {
func (d *DiscordNotifier) Validate(encryptor encryption.FieldEncryptor) error {
if d.ChannelWebhookURL == "" {
return errors.New("webhook URL is required")
}
@@ -29,7 +30,17 @@ func (d *DiscordNotifier) Validate() error {
return nil
}
func (d *DiscordNotifier) Send(logger *slog.Logger, heading string, message string) error {
func (d *DiscordNotifier) Send(
encryptor encryption.FieldEncryptor,
logger *slog.Logger,
heading string,
message string,
) error {
webhookURL, err := encryptor.Decrypt(d.NotifierID, d.ChannelWebhookURL)
if err != nil {
return fmt.Errorf("failed to decrypt webhook URL: %w", err)
}
fullMessage := heading
if message != "" {
fullMessage = fmt.Sprintf("%s\n\n%s", heading, message)
@@ -44,7 +55,7 @@ func (d *DiscordNotifier) Send(logger *slog.Logger, heading string, message stri
return fmt.Errorf("failed to marshal Discord payload: %w", err)
}
req, err := http.NewRequest("POST", d.ChannelWebhookURL, bytes.NewReader(jsonPayload))
req, err := http.NewRequest("POST", webhookURL, bytes.NewReader(jsonPayload))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
@@ -81,3 +92,14 @@ func (d *DiscordNotifier) Update(incoming *DiscordNotifier) {
d.ChannelWebhookURL = incoming.ChannelWebhookURL
}
}
func (d *DiscordNotifier) EncryptSensitiveData(encryptor encryption.FieldEncryptor) error {
if d.ChannelWebhookURL != "" {
encrypted, err := encryptor.Encrypt(d.NotifierID, d.ChannelWebhookURL)
if err != nil {
return fmt.Errorf("failed to encrypt webhook URL: %w", err)
}
d.ChannelWebhookURL = encrypted
}
return nil
}

View File

@@ -0,0 +1,28 @@
package email_notifier
import (
"errors"
"net/smtp"
)
type loginAuth struct {
username, password string
}
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
return "LOGIN", []byte{}, nil
}
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
if more {
switch string(fromServer) {
case "Username:":
return []byte(a.username), nil
case "Password:":
return []byte(a.password), nil
default:
return nil, errors.New("unknown LOGIN challenge: " + string(fromServer))
}
}
return nil, nil
}

View File

@@ -7,6 +7,7 @@ import (
"log/slog"
"net"
"net/smtp"
"postgresus-backend/internal/util/encryption"
"time"
"github.com/google/uuid"
@@ -34,7 +35,7 @@ func (e *EmailNotifier) TableName() string {
return "email_notifiers"
}
func (e *EmailNotifier) Validate() error {
func (e *EmailNotifier) Validate(encryptor encryption.FieldEncryptor) error {
if e.TargetEmail == "" {
return errors.New("target email is required")
}
@@ -55,8 +56,21 @@ func (e *EmailNotifier) Validate() error {
return nil
}
func (e *EmailNotifier) Send(logger *slog.Logger, heading string, message string) error {
// Compose email
func (e *EmailNotifier) Send(
encryptor encryption.FieldEncryptor,
_ *slog.Logger,
heading string,
message string,
) error {
var smtpPassword string
if e.SMTPPassword != "" {
decrypted, err := encryptor.Decrypt(e.NotifierID, e.SMTPPassword)
if err != nil {
return fmt.Errorf("failed to decrypt SMTP password: %w", err)
}
smtpPassword = decrypted
}
from := e.From
if from == "" {
from = e.SMTPUser
@@ -65,153 +79,13 @@ func (e *EmailNotifier) Send(logger *slog.Logger, heading string, message string
}
}
to := []string{e.TargetEmail}
emailContent := e.buildEmailContent(heading, message, from)
isAuthRequired := e.SMTPUser != "" && smtpPassword != ""
// Format the email content
subject := fmt.Sprintf("Subject: %s\r\n", heading)
mime := fmt.Sprintf(
"MIME-version: 1.0;\nContent-Type: %s; charset=\"%s\";\n\n",
MIMETypeHTML,
MIMECharsetUTF8,
)
body := message
fromHeader := fmt.Sprintf("From: %s\r\n", from)
toHeader := fmt.Sprintf("To: %s\r\n", e.TargetEmail)
// Combine all parts of the email
emailContent := []byte(fromHeader + toHeader + subject + mime + body)
addr := net.JoinHostPort(e.SMTPHost, fmt.Sprintf("%d", e.SMTPPort))
timeout := DefaultTimeout
// Determine if authentication is required
isAuthRequired := e.SMTPUser != "" && e.SMTPPassword != ""
// Handle different port scenarios
if e.SMTPPort == ImplicitTLSPort {
// Implicit TLS (port 465)
// Set up TLS config
tlsConfig := &tls.Config{
ServerName: e.SMTPHost,
}
// Dial with timeout
dialer := &net.Dialer{Timeout: timeout}
conn, err := tls.DialWithDialer(dialer, "tcp", addr, tlsConfig)
if err != nil {
return fmt.Errorf("failed to connect to SMTP server: %w", err)
}
defer func() {
_ = conn.Close()
}()
// Create SMTP client
client, err := smtp.NewClient(conn, e.SMTPHost)
if err != nil {
return fmt.Errorf("failed to create SMTP client: %w", err)
}
defer func() {
_ = client.Quit()
}()
// Set up authentication only if credentials are provided
if isAuthRequired {
auth := smtp.PlainAuth("", e.SMTPUser, e.SMTPPassword, e.SMTPHost)
if err := client.Auth(auth); err != nil {
return fmt.Errorf("SMTP authentication failed: %w", err)
}
}
// Set sender and recipients
if err := client.Mail(from); err != nil {
return fmt.Errorf("failed to set sender: %w", err)
}
for _, recipient := range to {
if err := client.Rcpt(recipient); err != nil {
return fmt.Errorf("failed to set recipient: %w", err)
}
}
// Send the email body
writer, err := client.Data()
if err != nil {
return fmt.Errorf("failed to get data writer: %w", err)
}
_, err = writer.Write(emailContent)
if err != nil {
return fmt.Errorf("failed to write email content: %w", err)
}
err = writer.Close()
if err != nil {
return fmt.Errorf("failed to close data writer: %w", err)
}
return nil
} else {
// STARTTLS (port 587) or other ports
// Create a custom dialer with timeout
dialer := &net.Dialer{Timeout: timeout}
conn, err := dialer.Dial("tcp", addr)
if err != nil {
return fmt.Errorf("failed to connect to SMTP server: %w", err)
}
// Create client from connection
client, err := smtp.NewClient(conn, e.SMTPHost)
if err != nil {
return fmt.Errorf("failed to create SMTP client: %w", err)
}
defer func() {
_ = client.Quit()
}()
// Send email using the client
if err := client.Hello(DefaultHelloName); err != nil {
return fmt.Errorf("SMTP hello failed: %w", err)
}
// Start TLS if available
if ok, _ := client.Extension("STARTTLS"); ok {
if err := client.StartTLS(&tls.Config{ServerName: e.SMTPHost}); err != nil {
return fmt.Errorf("STARTTLS failed: %w", err)
}
}
// Authenticate only if credentials are provided
if isAuthRequired {
auth := smtp.PlainAuth("", e.SMTPUser, e.SMTPPassword, e.SMTPHost)
if err := client.Auth(auth); err != nil {
return fmt.Errorf("SMTP authentication failed: %w", err)
}
}
if err := client.Mail(from); err != nil {
return fmt.Errorf("failed to set sender: %w", err)
}
for _, recipient := range to {
if err := client.Rcpt(recipient); err != nil {
return fmt.Errorf("failed to set recipient: %w", err)
}
}
writer, err := client.Data()
if err != nil {
return fmt.Errorf("failed to get data writer: %w", err)
}
_, err = writer.Write(emailContent)
if err != nil {
return fmt.Errorf("failed to write email content: %w", err)
}
err = writer.Close()
if err != nil {
return fmt.Errorf("failed to close data writer: %w", err)
}
return client.Quit()
return e.sendImplicitTLS(emailContent, from, smtpPassword, isAuthRequired)
}
return e.sendStartTLS(emailContent, from, smtpPassword, isAuthRequired)
}
func (e *EmailNotifier) HideSensitiveData() {
@@ -229,3 +103,177 @@ func (e *EmailNotifier) Update(incoming *EmailNotifier) {
e.SMTPPassword = incoming.SMTPPassword
}
}
func (e *EmailNotifier) EncryptSensitiveData(encryptor encryption.FieldEncryptor) error {
if e.SMTPPassword != "" {
encrypted, err := encryptor.Encrypt(e.NotifierID, e.SMTPPassword)
if err != nil {
return fmt.Errorf("failed to encrypt SMTP password: %w", err)
}
e.SMTPPassword = encrypted
}
return nil
}
func (e *EmailNotifier) buildEmailContent(heading, message, from string) []byte {
subject := fmt.Sprintf("Subject: %s\r\n", heading)
mime := fmt.Sprintf(
"MIME-version: 1.0;\nContent-Type: %s; charset=\"%s\";\n\n",
MIMETypeHTML,
MIMECharsetUTF8,
)
fromHeader := fmt.Sprintf("From: %s\r\n", from)
toHeader := fmt.Sprintf("To: %s\r\n", e.TargetEmail)
return []byte(fromHeader + toHeader + subject + mime + message)
}
func (e *EmailNotifier) sendImplicitTLS(
emailContent []byte,
from string,
password string,
isAuthRequired bool,
) error {
createClient := func() (*smtp.Client, func(), error) {
return e.createImplicitTLSClient()
}
client, cleanup, err := e.authenticateWithRetry(createClient, password, isAuthRequired)
if err != nil {
return err
}
defer cleanup()
return e.sendEmail(client, from, emailContent)
}
func (e *EmailNotifier) sendStartTLS(
emailContent []byte,
from string,
password string,
isAuthRequired bool,
) error {
createClient := func() (*smtp.Client, func(), error) {
return e.createStartTLSClient()
}
client, cleanup, err := e.authenticateWithRetry(createClient, password, isAuthRequired)
if err != nil {
return err
}
defer cleanup()
return e.sendEmail(client, from, emailContent)
}
func (e *EmailNotifier) createImplicitTLSClient() (*smtp.Client, func(), error) {
addr := net.JoinHostPort(e.SMTPHost, fmt.Sprintf("%d", e.SMTPPort))
tlsConfig := &tls.Config{ServerName: e.SMTPHost}
dialer := &net.Dialer{Timeout: DefaultTimeout}
conn, err := tls.DialWithDialer(dialer, "tcp", addr, tlsConfig)
if err != nil {
return nil, nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
}
client, err := smtp.NewClient(conn, e.SMTPHost)
if err != nil {
_ = conn.Close()
return nil, nil, fmt.Errorf("failed to create SMTP client: %w", err)
}
return client, func() { _ = client.Quit() }, nil
}
func (e *EmailNotifier) createStartTLSClient() (*smtp.Client, func(), error) {
addr := net.JoinHostPort(e.SMTPHost, fmt.Sprintf("%d", e.SMTPPort))
dialer := &net.Dialer{Timeout: DefaultTimeout}
conn, err := dialer.Dial("tcp", addr)
if err != nil {
return nil, nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
}
client, err := smtp.NewClient(conn, e.SMTPHost)
if err != nil {
_ = conn.Close()
return nil, nil, fmt.Errorf("failed to create SMTP client: %w", err)
}
if err := client.Hello(DefaultHelloName); err != nil {
_ = client.Quit()
_ = conn.Close()
return nil, nil, fmt.Errorf("SMTP hello failed: %w", err)
}
if ok, _ := client.Extension("STARTTLS"); ok {
if err := client.StartTLS(&tls.Config{ServerName: e.SMTPHost}); err != nil {
_ = client.Quit()
_ = conn.Close()
return nil, nil, fmt.Errorf("STARTTLS failed: %w", err)
}
}
return client, func() { _ = client.Quit() }, nil
}
func (e *EmailNotifier) authenticateWithRetry(
createClient func() (*smtp.Client, func(), error),
password string,
isAuthRequired bool,
) (*smtp.Client, func(), error) {
client, cleanup, err := createClient()
if err != nil {
return nil, nil, err
}
if !isAuthRequired {
return client, cleanup, nil
}
// Try PLAIN auth first
plainAuth := smtp.PlainAuth("", e.SMTPUser, password, e.SMTPHost)
if err := client.Auth(plainAuth); err == nil {
return client, cleanup, nil
}
// PLAIN auth failed, connection may be closed - recreate and try LOGIN auth
cleanup()
client, cleanup, err = createClient()
if err != nil {
return nil, nil, err
}
loginAuth := &loginAuth{username: e.SMTPUser, password: password}
if err := client.Auth(loginAuth); err != nil {
cleanup()
return nil, nil, fmt.Errorf("SMTP authentication failed: %w", err)
}
return client, cleanup, nil
}
func (e *EmailNotifier) sendEmail(client *smtp.Client, from string, content []byte) error {
if err := client.Mail(from); err != nil {
return fmt.Errorf("failed to set sender: %w", err)
}
if err := client.Rcpt(e.TargetEmail); err != nil {
return fmt.Errorf("failed to set recipient: %w", err)
}
writer, err := client.Data()
if err != nil {
return fmt.Errorf("failed to get data writer: %w", err)
}
if _, err = writer.Write(content); err != nil {
return fmt.Errorf("failed to write email content: %w", err)
}
if err = writer.Close(); err != nil {
return fmt.Errorf("failed to close data writer: %w", err)
}
return nil
}

View File

@@ -8,6 +8,7 @@ import (
"io"
"log/slog"
"net/http"
"postgresus-backend/internal/util/encryption"
"strconv"
"strings"
"time"
@@ -23,7 +24,7 @@ type SlackNotifier struct {
func (s *SlackNotifier) TableName() string { return "slack_notifiers" }
func (s *SlackNotifier) Validate() error {
func (s *SlackNotifier) Validate(encryptor encryption.FieldEncryptor) error {
if s.BotToken == "" {
return errors.New("bot token is required")
}
@@ -43,7 +44,16 @@ func (s *SlackNotifier) Validate() error {
return nil
}
func (s *SlackNotifier) Send(logger *slog.Logger, heading, message string) error {
func (s *SlackNotifier) Send(
encryptor encryption.FieldEncryptor,
logger *slog.Logger,
heading, message string,
) error {
botToken, err := encryptor.Decrypt(s.NotifierID, s.BotToken)
if err != nil {
return fmt.Errorf("failed to decrypt bot token: %w", err)
}
full := fmt.Sprintf("*%s*", heading)
if message != "" {
@@ -60,6 +70,7 @@ func (s *SlackNotifier) Send(logger *slog.Logger, heading, message string) error
maxAttempts = 5
defaultBackoff = 2 * time.Second // when Retry-After header missing
backoffMultiplier = 1.5 // use exponential growth
requestTimeout = 30 * time.Second
)
var (
@@ -67,6 +78,10 @@ func (s *SlackNotifier) Send(logger *slog.Logger, heading, message string) error
attempts = 0
)
client := &http.Client{
Timeout: requestTimeout,
}
for {
attempts++
@@ -80,9 +95,9 @@ func (s *SlackNotifier) Send(logger *slog.Logger, heading, message string) error
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
req.Header.Set("Authorization", "Bearer "+s.BotToken)
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)
}
@@ -144,3 +159,14 @@ func (s *SlackNotifier) Update(incoming *SlackNotifier) {
s.BotToken = incoming.BotToken
}
}
func (s *SlackNotifier) EncryptSensitiveData(encryptor encryption.FieldEncryptor) error {
if s.BotToken != "" {
encrypted, err := encryptor.Encrypt(s.NotifierID, s.BotToken)
if err != nil {
return fmt.Errorf("failed to encrypt bot token: %w", err)
}
s.BotToken = encrypted
}
return nil
}

View File

@@ -8,6 +8,7 @@ import (
"log/slog"
"net/http"
"net/url"
"postgresus-backend/internal/util/encryption"
"github.com/google/uuid"
)
@@ -21,11 +22,17 @@ func (TeamsNotifier) TableName() string {
return "teams_notifiers"
}
func (n *TeamsNotifier) Validate() error {
func (n *TeamsNotifier) Validate(encryptor encryption.FieldEncryptor) error {
if n.WebhookURL == "" {
return errors.New("webhook_url is required")
}
u, err := url.Parse(n.WebhookURL)
webhookURL, err := encryptor.Decrypt(n.NotifierID, n.WebhookURL)
if err != nil {
return fmt.Errorf("failed to decrypt webhook URL: %w", err)
}
u, err := url.Parse(webhookURL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
return errors.New("invalid webhook_url")
}
@@ -33,8 +40,8 @@ func (n *TeamsNotifier) Validate() error {
}
type cardAttachment struct {
ContentType string `json:"contentType"`
Content interface{} `json:"content"`
ContentType string `json:"contentType"`
Content any `json:"content"`
}
type payload struct {
@@ -43,11 +50,20 @@ type payload struct {
Attachments []cardAttachment `json:"attachments,omitempty"`
}
func (n *TeamsNotifier) Send(logger *slog.Logger, heading, message string) error {
if err := n.Validate(); err != nil {
func (n *TeamsNotifier) Send(
encryptor encryption.FieldEncryptor,
logger *slog.Logger,
heading, message string,
) error {
if err := n.Validate(encryptor); err != nil {
return err
}
webhookURL, err := encryptor.Decrypt(n.NotifierID, n.WebhookURL)
if err != nil {
return fmt.Errorf("failed to decrypt webhook URL: %w", err)
}
card := map[string]any{
"type": "AdaptiveCard",
"version": "1.4",
@@ -71,7 +87,7 @@ func (n *TeamsNotifier) Send(logger *slog.Logger, heading, message string) error
}
body, _ := json.Marshal(p)
req, err := http.NewRequest(http.MethodPost, n.WebhookURL, bytes.NewReader(body))
req, err := http.NewRequest(http.MethodPost, webhookURL, bytes.NewReader(body))
if err != nil {
return err
}
@@ -104,3 +120,14 @@ func (n *TeamsNotifier) Update(incoming *TeamsNotifier) {
n.WebhookURL = incoming.WebhookURL
}
}
func (n *TeamsNotifier) EncryptSensitiveData(encryptor encryption.FieldEncryptor) error {
if n.WebhookURL != "" {
encrypted, err := encryptor.Encrypt(n.NotifierID, n.WebhookURL)
if err != nil {
return fmt.Errorf("failed to encrypt webhook URL: %w", err)
}
n.WebhookURL = encrypted
}
return nil
}

View File

@@ -7,6 +7,7 @@ import (
"log/slog"
"net/http"
"net/url"
"postgresus-backend/internal/util/encryption"
"strconv"
"strings"
@@ -24,7 +25,7 @@ func (t *TelegramNotifier) TableName() string {
return "telegram_notifiers"
}
func (t *TelegramNotifier) Validate() error {
func (t *TelegramNotifier) Validate(encryptor encryption.FieldEncryptor) error {
if t.BotToken == "" {
return errors.New("bot token is required")
}
@@ -36,13 +37,23 @@ func (t *TelegramNotifier) Validate() error {
return nil
}
func (t *TelegramNotifier) Send(logger *slog.Logger, heading string, message string) error {
func (t *TelegramNotifier) Send(
encryptor encryption.FieldEncryptor,
logger *slog.Logger,
heading string,
message string,
) error {
botToken, err := encryptor.Decrypt(t.NotifierID, t.BotToken)
if err != nil {
return fmt.Errorf("failed to decrypt bot token: %w", err)
}
fullMessage := heading
if message != "" {
fullMessage = fmt.Sprintf("%s\n\n%s", heading, message)
}
apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", t.BotToken)
apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken)
data := url.Values{}
data.Set("chat_id", t.TargetChatID)
@@ -93,3 +104,14 @@ func (t *TelegramNotifier) Update(incoming *TelegramNotifier) {
t.BotToken = incoming.BotToken
}
}
func (t *TelegramNotifier) EncryptSensitiveData(encryptor encryption.FieldEncryptor) error {
if t.BotToken != "" {
encrypted, err := encryptor.Encrypt(t.NotifierID, t.BotToken)
if err != nil {
return fmt.Errorf("failed to encrypt bot token: %w", err)
}
t.BotToken = encrypted
}
return nil
}

View File

@@ -9,21 +9,59 @@ import (
"log/slog"
"net/http"
"net/url"
"postgresus-backend/internal/util/encryption"
"strings"
"github.com/google/uuid"
"gorm.io/gorm"
)
type WebhookHeader struct {
Key string `json:"key"`
Value string `json:"value"`
}
type WebhookNotifier struct {
NotifierID uuid.UUID `json:"notifierId" gorm:"primaryKey;column:notifier_id"`
WebhookURL string `json:"webhookUrl" gorm:"not null;column:webhook_url"`
WebhookMethod WebhookMethod `json:"webhookMethod" gorm:"not null;column:webhook_method"`
BodyTemplate *string `json:"bodyTemplate" gorm:"column:body_template;type:text"`
HeadersJSON string `json:"-" gorm:"column:headers;type:text"`
Headers []WebhookHeader `json:"headers" gorm:"-"`
}
func (t *WebhookNotifier) TableName() string {
return "webhook_notifiers"
}
func (t *WebhookNotifier) Validate() error {
func (t *WebhookNotifier) BeforeSave(_ *gorm.DB) error {
if len(t.Headers) > 0 {
data, err := json.Marshal(t.Headers)
if err != nil {
return err
}
t.HeadersJSON = string(data)
} else {
t.HeadersJSON = "[]"
}
return nil
}
func (t *WebhookNotifier) AfterFind(_ *gorm.DB) error {
if t.HeadersJSON != "" {
if err := json.Unmarshal([]byte(t.HeadersJSON), &t.Headers); err != nil {
return err
}
}
return nil
}
func (t *WebhookNotifier) Validate(encryptor encryption.FieldEncryptor) error {
if t.WebhookURL == "" {
return errors.New("webhook URL is required")
}
@@ -35,69 +73,22 @@ func (t *WebhookNotifier) Validate() error {
return nil
}
func (t *WebhookNotifier) Send(logger *slog.Logger, heading string, message string) error {
func (t *WebhookNotifier) Send(
encryptor encryption.FieldEncryptor,
logger *slog.Logger,
heading string,
message string,
) error {
webhookURL, err := encryptor.Decrypt(t.NotifierID, t.WebhookURL)
if err != nil {
return fmt.Errorf("failed to decrypt webhook URL: %w", err)
}
switch t.WebhookMethod {
case WebhookMethodGET:
reqURL := fmt.Sprintf("%s?heading=%s&message=%s",
t.WebhookURL,
url.QueryEscape(heading),
url.QueryEscape(message),
)
resp, err := http.Get(reqURL)
if err != nil {
return fmt.Errorf("failed to send GET webhook: %w", err)
}
defer func() {
if cerr := resp.Body.Close(); cerr != nil {
logger.Error("failed to close response body", "error", cerr)
}
}()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf(
"webhook GET returned status: %s, body: %s",
resp.Status,
string(body),
)
}
return nil
return t.sendGET(webhookURL, heading, message, logger)
case WebhookMethodPOST:
payload := map[string]string{
"heading": heading,
"message": message,
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal webhook payload: %w", err)
}
resp, err := http.Post(t.WebhookURL, "application/json", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("failed to send POST webhook: %w", err)
}
defer func() {
if cerr := resp.Body.Close(); cerr != nil {
logger.Error("failed to close response body", "error", cerr)
}
}()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf(
"webhook POST returned status: %s, body: %s",
resp.Status,
string(body),
)
}
return nil
return t.sendPOST(webhookURL, heading, message, logger)
default:
return fmt.Errorf("unsupported webhook method: %s", t.WebhookMethod)
}
@@ -109,4 +100,130 @@ func (t *WebhookNotifier) HideSensitiveData() {
func (t *WebhookNotifier) Update(incoming *WebhookNotifier) {
t.WebhookURL = incoming.WebhookURL
t.WebhookMethod = incoming.WebhookMethod
t.BodyTemplate = incoming.BodyTemplate
t.Headers = incoming.Headers
}
func (t *WebhookNotifier) EncryptSensitiveData(encryptor encryption.FieldEncryptor) error {
if t.WebhookURL != "" {
encrypted, err := encryptor.Encrypt(t.NotifierID, t.WebhookURL)
if err != nil {
return fmt.Errorf("failed to encrypt webhook URL: %w", err)
}
t.WebhookURL = encrypted
}
return nil
}
func (t *WebhookNotifier) sendGET(webhookURL, heading, message string, logger *slog.Logger) error {
reqURL := fmt.Sprintf("%s?heading=%s&message=%s",
webhookURL,
url.QueryEscape(heading),
url.QueryEscape(message),
)
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
if err != nil {
return fmt.Errorf("failed to create GET request: %w", err)
}
t.applyHeaders(req)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to send GET webhook: %w", err)
}
defer func() {
if cerr := resp.Body.Close(); cerr != nil {
logger.Error("failed to close response body", "error", cerr)
}
}()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf(
"webhook GET returned status: %s, body: %s",
resp.Status,
string(body),
)
}
return nil
}
func (t *WebhookNotifier) sendPOST(webhookURL, heading, message string, logger *slog.Logger) error {
body := t.buildRequestBody(heading, message)
req, err := http.NewRequest(http.MethodPost, webhookURL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("failed to create POST request: %w", err)
}
hasContentType := false
for _, h := range t.Headers {
if strings.EqualFold(h.Key, "Content-Type") {
hasContentType = true
break
}
}
if !hasContentType {
req.Header.Set("Content-Type", "application/json")
}
t.applyHeaders(req)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to send POST webhook: %w", err)
}
defer func() {
if cerr := resp.Body.Close(); cerr != nil {
logger.Error("failed to close response body", "error", cerr)
}
}()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf(
"webhook POST returned status: %s, body: %s",
resp.Status,
string(respBody),
)
}
return nil
}
func (t *WebhookNotifier) buildRequestBody(heading, message string) []byte {
if t.BodyTemplate != nil && *t.BodyTemplate != "" {
result := *t.BodyTemplate
result = strings.ReplaceAll(result, "{{heading}}", heading)
result = strings.ReplaceAll(result, "{{message}}", message)
return []byte(result)
}
payload := map[string]string{
"heading": heading,
"message": message,
}
body, _ := json.Marshal(payload)
return body
}
func (t *WebhookNotifier) applyHeaders(req *http.Request) {
for _, h := range t.Headers {
if h.Key != "" {
req.Header.Set(h.Key, h.Value)
}
}
}

View File

@@ -8,6 +8,7 @@ import (
audit_logs "postgresus-backend/internal/features/audit_logs"
users_models "postgresus-backend/internal/features/users/models"
workspaces_services "postgresus-backend/internal/features/workspaces/services"
"postgresus-backend/internal/util/encryption"
"github.com/google/uuid"
)
@@ -17,6 +18,7 @@ type NotifierService struct {
logger *slog.Logger
workspaceService *workspaces_services.WorkspaceService
auditLogService *audit_logs.AuditLogService
fieldEncryptor encryption.FieldEncryptor
}
func (s *NotifierService) SaveNotifier(
@@ -46,7 +48,11 @@ func (s *NotifierService) SaveNotifier(
existingNotifier.Update(notifier)
if err := existingNotifier.Validate(); err != nil {
if err := existingNotifier.EncryptSensitiveData(s.fieldEncryptor); err != nil {
return err
}
if err := existingNotifier.Validate(s.fieldEncryptor); err != nil {
return err
}
@@ -63,7 +69,11 @@ func (s *NotifierService) SaveNotifier(
} else {
notifier.WorkspaceID = workspaceID
if err := notifier.Validate(); err != nil {
if err := notifier.EncryptSensitiveData(s.fieldEncryptor); err != nil {
return err
}
if err := notifier.Validate(s.fieldEncryptor); err != nil {
return err
}
@@ -175,7 +185,7 @@ func (s *NotifierService) SendTestNotification(
return errors.New("insufficient permissions to test notifier in this workspace")
}
err = notifier.Send(s.logger, "Test message", "This is a test message")
err = notifier.Send(s.fieldEncryptor, s.logger, "Test message", "This is a test message")
if err != nil {
return err
}
@@ -205,16 +215,24 @@ func (s *NotifierService) SendTestNotificationToNotifier(
existingNotifier.Update(notifier)
if err := existingNotifier.Validate(); err != nil {
if err := existingNotifier.EncryptSensitiveData(s.fieldEncryptor); err != nil {
return err
}
if err := existingNotifier.Validate(s.fieldEncryptor); err != nil {
return err
}
usingNotifier = existingNotifier
} else {
if err := notifier.EncryptSensitiveData(s.fieldEncryptor); err != nil {
return err
}
usingNotifier = notifier
}
return usingNotifier.Send(s.logger, "Test message", "This is a test message")
return usingNotifier.Send(s.fieldEncryptor, s.logger, "Test message", "This is a test message")
}
func (s *NotifierService) SendNotification(
@@ -233,7 +251,7 @@ func (s *NotifierService) SendNotification(
return
}
err = notifiedFromDb.Send(s.logger, title, message)
err = notifiedFromDb.Send(s.fieldEncryptor, s.logger, title, message)
if err != nil {
errMsg := err.Error()
notifiedFromDb.LastSendError = &errMsg

View File

@@ -29,6 +29,7 @@ import (
workspaces_controllers "postgresus-backend/internal/features/workspaces/controllers"
workspaces_models "postgresus-backend/internal/features/workspaces/models"
workspaces_testing "postgresus-backend/internal/features/workspaces/testing"
util_encryption "postgresus-backend/internal/util/encryption"
test_utils "postgresus-backend/internal/util/testing"
"postgresus-backend/internal/util/tools"
)
@@ -309,6 +310,7 @@ func createTestBackup(
database *databases.Database,
owner *users_dto.SignInResponseDTO,
) *backups.Backup {
fieldEncryptor := util_encryption.GetFieldEncryptor()
userService := users_services.GetUserService()
user, err := userService.GetUserFromToken(owner.Token)
if err != nil {
@@ -338,7 +340,7 @@ func createTestBackup(
dummyContent := []byte("dummy backup content for testing")
reader := strings.NewReader(string(dummyContent))
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if err := storages[0].SaveFile(logger, backup.ID, reader); err != nil {
if err := storages[0].SaveFile(fieldEncryptor, logger, backup.ID, reader); err != nil {
panic(fmt.Sprintf("Failed to create test backup file: %v", 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,10 @@ 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"
@@ -30,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")
}
@@ -53,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")
}
@@ -77,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",
@@ -209,7 +212,8 @@ func (uc *RestorePostgresqlBackupUsecase) downloadBackupToTempFile(
"encrypted",
backup.Encryption == backups_config.BackupEncryptionEncrypted,
)
rawReader, err := storage.GetFile(backup.ID)
fieldEncryptor := util_encryption.GetFieldEncryptor()
rawReader, err := storage.GetFile(fieldEncryptor, backup.ID)
if err != nil {
cleanupFunc()
return "", nil, fmt.Errorf("failed to get backup file from storage: %w", err)
@@ -230,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)
@@ -560,11 +564,15 @@ func (uc *RestorePostgresqlBackupUsecase) createTempPgpassFile(
return "", nil
}
escapedHost := tools.EscapePgpassField(pgConfig.Host)
escapedUsername := tools.EscapePgpassField(pgConfig.Username)
escapedPassword := tools.EscapePgpassField(password)
pgpassContent := fmt.Sprintf("%s:%d:*:%s:%s",
pgConfig.Host,
escapedHost,
pgConfig.Port,
pgConfig.Username,
password,
escapedUsername,
escapedPassword,
)
tempDir, err := os.MkdirTemp("", "pgpass")

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

@@ -3,10 +3,14 @@ package storages
import (
"fmt"
"net/http"
"strings"
"testing"
audit_logs "postgresus-backend/internal/features/audit_logs"
azure_blob_storage "postgresus-backend/internal/features/storages/models/azure_blob"
google_drive_storage "postgresus-backend/internal/features/storages/models/google_drive"
local_storage "postgresus-backend/internal/features/storages/models/local"
nas_storage "postgresus-backend/internal/features/storages/models/nas"
s3_storage "postgresus-backend/internal/features/storages/models/s3"
users_enums "postgresus-backend/internal/features/users/enums"
users_middleware "postgresus-backend/internal/features/users/middleware"
@@ -14,6 +18,7 @@ import (
users_testing "postgresus-backend/internal/features/users/testing"
workspaces_controllers "postgresus-backend/internal/features/workspaces/controllers"
workspaces_testing "postgresus-backend/internal/features/workspaces/testing"
"postgresus-backend/internal/util/encryption"
test_utils "postgresus-backend/internal/util/testing"
"github.com/gin-gonic/gin"
@@ -438,6 +443,386 @@ func Test_CrossWorkspaceSecurity_CannotAccessStorageFromAnotherWorkspace(t *test
workspaces_testing.RemoveTestWorkspace(workspace2, router)
}
func Test_StorageSensitiveDataLifecycle_AllTypes(t *testing.T) {
testCases := []struct {
name string
storageType StorageType
createStorage func(workspaceID uuid.UUID) *Storage
updateStorage func(workspaceID uuid.UUID, storageID uuid.UUID) *Storage
verifySensitiveData func(t *testing.T, storage *Storage)
verifyHiddenData func(t *testing.T, storage *Storage)
}{
{
name: "S3 Storage",
storageType: StorageTypeS3,
createStorage: func(workspaceID uuid.UUID) *Storage {
return &Storage{
WorkspaceID: workspaceID,
Type: StorageTypeS3,
Name: "Test S3 Storage",
S3Storage: &s3_storage.S3Storage{
S3Bucket: "test-bucket",
S3Region: "us-east-1",
S3AccessKey: "original-access-key",
S3SecretKey: "original-secret-key",
S3Endpoint: "https://s3.amazonaws.com",
},
}
},
updateStorage: func(workspaceID uuid.UUID, storageID uuid.UUID) *Storage {
return &Storage{
ID: storageID,
WorkspaceID: workspaceID,
Type: StorageTypeS3,
Name: "Updated S3 Storage",
S3Storage: &s3_storage.S3Storage{
S3Bucket: "updated-bucket",
S3Region: "us-west-2",
S3AccessKey: "",
S3SecretKey: "",
S3Endpoint: "https://s3.us-west-2.amazonaws.com",
},
}
},
verifySensitiveData: func(t *testing.T, storage *Storage) {
assert.True(t, strings.HasPrefix(storage.S3Storage.S3AccessKey, "enc:"),
"S3AccessKey should be encrypted with 'enc:' prefix")
assert.True(t, strings.HasPrefix(storage.S3Storage.S3SecretKey, "enc:"),
"S3SecretKey should be encrypted with 'enc:' prefix")
encryptor := encryption.GetFieldEncryptor()
accessKey, err := encryptor.Decrypt(storage.ID, storage.S3Storage.S3AccessKey)
assert.NoError(t, err)
assert.Equal(t, "original-access-key", accessKey)
secretKey, err := encryptor.Decrypt(storage.ID, storage.S3Storage.S3SecretKey)
assert.NoError(t, err)
assert.Equal(t, "original-secret-key", secretKey)
},
verifyHiddenData: func(t *testing.T, storage *Storage) {
assert.Equal(t, "", storage.S3Storage.S3AccessKey)
assert.Equal(t, "", storage.S3Storage.S3SecretKey)
},
},
{
name: "Local Storage",
storageType: StorageTypeLocal,
createStorage: func(workspaceID uuid.UUID) *Storage {
return &Storage{
WorkspaceID: workspaceID,
Type: StorageTypeLocal,
Name: "Test Local Storage",
LocalStorage: &local_storage.LocalStorage{},
}
},
updateStorage: func(workspaceID uuid.UUID, storageID uuid.UUID) *Storage {
return &Storage{
ID: storageID,
WorkspaceID: workspaceID,
Type: StorageTypeLocal,
Name: "Updated Local Storage",
LocalStorage: &local_storage.LocalStorage{},
}
},
verifySensitiveData: func(t *testing.T, storage *Storage) {
},
verifyHiddenData: func(t *testing.T, storage *Storage) {
},
},
{
name: "NAS Storage",
storageType: StorageTypeNAS,
createStorage: func(workspaceID uuid.UUID) *Storage {
return &Storage{
WorkspaceID: workspaceID,
Type: StorageTypeNAS,
Name: "Test NAS Storage",
NASStorage: &nas_storage.NASStorage{
Host: "nas.example.com",
Port: 445,
Share: "backups",
Username: "testuser",
Password: "original-password",
UseSSL: false,
Domain: "WORKGROUP",
Path: "/test",
},
}
},
updateStorage: func(workspaceID uuid.UUID, storageID uuid.UUID) *Storage {
return &Storage{
ID: storageID,
WorkspaceID: workspaceID,
Type: StorageTypeNAS,
Name: "Updated NAS Storage",
NASStorage: &nas_storage.NASStorage{
Host: "nas2.example.com",
Port: 445,
Share: "backups2",
Username: "testuser2",
Password: "",
UseSSL: true,
Domain: "WORKGROUP2",
Path: "/test2",
},
}
},
verifySensitiveData: func(t *testing.T, storage *Storage) {
assert.True(t, strings.HasPrefix(storage.NASStorage.Password, "enc:"),
"Password should be encrypted with 'enc:' prefix")
encryptor := encryption.GetFieldEncryptor()
password, err := encryptor.Decrypt(storage.ID, storage.NASStorage.Password)
assert.NoError(t, err)
assert.Equal(t, "original-password", password)
},
verifyHiddenData: func(t *testing.T, storage *Storage) {
assert.Equal(t, "", storage.NASStorage.Password)
},
},
{
name: "Azure Blob Storage (Connection String)",
storageType: StorageTypeAzureBlob,
createStorage: func(workspaceID uuid.UUID) *Storage {
return &Storage{
WorkspaceID: workspaceID,
Type: StorageTypeAzureBlob,
Name: "Test Azure Blob Storage",
AzureBlobStorage: &azure_blob_storage.AzureBlobStorage{
AuthMethod: azure_blob_storage.AuthMethodConnectionString,
ConnectionString: "original-connection-string",
ContainerName: "test-container",
Endpoint: "",
Prefix: "backups/",
},
}
},
updateStorage: func(workspaceID uuid.UUID, storageID uuid.UUID) *Storage {
return &Storage{
ID: storageID,
WorkspaceID: workspaceID,
Type: StorageTypeAzureBlob,
Name: "Updated Azure Blob Storage",
AzureBlobStorage: &azure_blob_storage.AzureBlobStorage{
AuthMethod: azure_blob_storage.AuthMethodConnectionString,
ConnectionString: "",
ContainerName: "updated-container",
Endpoint: "https://custom.blob.core.windows.net",
Prefix: "backups2/",
},
}
},
verifySensitiveData: func(t *testing.T, storage *Storage) {
assert.True(t, strings.HasPrefix(storage.AzureBlobStorage.ConnectionString, "enc:"),
"ConnectionString should be encrypted with 'enc:' prefix")
encryptor := encryption.GetFieldEncryptor()
connectionString, err := encryptor.Decrypt(
storage.ID,
storage.AzureBlobStorage.ConnectionString,
)
assert.NoError(t, err)
assert.Equal(t, "original-connection-string", connectionString)
},
verifyHiddenData: func(t *testing.T, storage *Storage) {
assert.Equal(t, "", storage.AzureBlobStorage.ConnectionString)
assert.Equal(t, "", storage.AzureBlobStorage.AccountKey)
},
},
{
name: "Azure Blob Storage (Account Key)",
storageType: StorageTypeAzureBlob,
createStorage: func(workspaceID uuid.UUID) *Storage {
return &Storage{
WorkspaceID: workspaceID,
Type: StorageTypeAzureBlob,
Name: "Test Azure Blob with Account Key",
AzureBlobStorage: &azure_blob_storage.AzureBlobStorage{
AuthMethod: azure_blob_storage.AuthMethodAccountKey,
AccountName: "testaccount",
AccountKey: "original-account-key",
ContainerName: "test-container",
Endpoint: "",
Prefix: "backups/",
},
}
},
updateStorage: func(workspaceID uuid.UUID, storageID uuid.UUID) *Storage {
return &Storage{
ID: storageID,
WorkspaceID: workspaceID,
Type: StorageTypeAzureBlob,
Name: "Updated Azure Blob with Account Key",
AzureBlobStorage: &azure_blob_storage.AzureBlobStorage{
AuthMethod: azure_blob_storage.AuthMethodAccountKey,
AccountName: "updatedaccount",
AccountKey: "",
ContainerName: "updated-container",
Endpoint: "https://custom.blob.core.windows.net",
Prefix: "backups2/",
},
}
},
verifySensitiveData: func(t *testing.T, storage *Storage) {
assert.True(t, strings.HasPrefix(storage.AzureBlobStorage.AccountKey, "enc:"),
"AccountKey should be encrypted with 'enc:' prefix")
encryptor := encryption.GetFieldEncryptor()
accountKey, err := encryptor.Decrypt(
storage.ID,
storage.AzureBlobStorage.AccountKey,
)
assert.NoError(t, err)
assert.Equal(t, "original-account-key", accountKey)
},
verifyHiddenData: func(t *testing.T, storage *Storage) {
assert.Equal(t, "", storage.AzureBlobStorage.ConnectionString)
assert.Equal(t, "", storage.AzureBlobStorage.AccountKey)
},
},
{
name: "Google Drive Storage",
storageType: StorageTypeGoogleDrive,
createStorage: func(workspaceID uuid.UUID) *Storage {
return &Storage{
WorkspaceID: workspaceID,
Type: StorageTypeGoogleDrive,
Name: "Test Google Drive Storage",
GoogleDriveStorage: &google_drive_storage.GoogleDriveStorage{
ClientID: "original-client-id",
ClientSecret: "original-client-secret",
TokenJSON: `{"access_token":"ya29.test-access-token","token_type":"Bearer","expiry":"2030-12-31T23:59:59Z","refresh_token":"1//test-refresh-token"}`,
},
}
},
updateStorage: func(workspaceID uuid.UUID, storageID uuid.UUID) *Storage {
return &Storage{
ID: storageID,
WorkspaceID: workspaceID,
Type: StorageTypeGoogleDrive,
Name: "Updated Google Drive Storage",
GoogleDriveStorage: &google_drive_storage.GoogleDriveStorage{
ClientID: "updated-client-id",
ClientSecret: "",
TokenJSON: "",
},
}
},
verifySensitiveData: func(t *testing.T, storage *Storage) {
assert.True(t, strings.HasPrefix(storage.GoogleDriveStorage.ClientSecret, "enc:"),
"ClientSecret should be encrypted with 'enc:' prefix")
assert.True(t, strings.HasPrefix(storage.GoogleDriveStorage.TokenJSON, "enc:"),
"TokenJSON should be encrypted with 'enc:' prefix")
encryptor := encryption.GetFieldEncryptor()
clientSecret, err := encryptor.Decrypt(
storage.ID,
storage.GoogleDriveStorage.ClientSecret,
)
assert.NoError(t, err)
assert.Equal(t, "original-client-secret", clientSecret)
tokenJSON, err := encryptor.Decrypt(
storage.ID,
storage.GoogleDriveStorage.TokenJSON,
)
assert.NoError(t, err)
assert.Equal(
t,
`{"access_token":"ya29.test-access-token","token_type":"Bearer","expiry":"2030-12-31T23:59:59Z","refresh_token":"1//test-refresh-token"}`,
tokenJSON,
)
},
verifyHiddenData: func(t *testing.T, storage *Storage) {
assert.Equal(t, "", storage.GoogleDriveStorage.ClientSecret)
assert.Equal(t, "", storage.GoogleDriveStorage.TokenJSON)
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
router := createRouter()
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
// Phase 1: Create storage with sensitive data
initialStorage := tc.createStorage(workspace.ID)
var createdStorage Storage
test_utils.MakePostRequestAndUnmarshal(
t,
router,
"/api/v1/storages",
"Bearer "+owner.Token,
*initialStorage,
http.StatusOK,
&createdStorage,
)
assert.NotEmpty(t, createdStorage.ID)
assert.Equal(t, initialStorage.Name, createdStorage.Name)
// Phase 2: Verify sensitive data is encrypted in repository after creation
repository := &StorageRepository{}
storageFromDBAfterCreate, err := repository.FindByID(createdStorage.ID)
assert.NoError(t, err)
tc.verifySensitiveData(t, storageFromDBAfterCreate)
// Phase 3: Read via service - sensitive data should be hidden
var retrievedStorage Storage
test_utils.MakeGetRequestAndUnmarshal(
t,
router,
fmt.Sprintf("/api/v1/storages/%s", createdStorage.ID.String()),
"Bearer "+owner.Token,
http.StatusOK,
&retrievedStorage,
)
tc.verifyHiddenData(t, &retrievedStorage)
assert.Equal(t, initialStorage.Name, retrievedStorage.Name)
// Phase 4: Update with non-sensitive changes only (sensitive fields empty)
updatedStorage := tc.updateStorage(workspace.ID, createdStorage.ID)
var updateResponse Storage
test_utils.MakePostRequestAndUnmarshal(
t,
router,
"/api/v1/storages",
"Bearer "+owner.Token,
*updatedStorage,
http.StatusOK,
&updateResponse,
)
// Verify non-sensitive fields were updated
assert.Equal(t, updatedStorage.Name, updateResponse.Name)
// Phase 5: Retrieve directly from repository to verify sensitive data preservation
storageFromDB, err := repository.FindByID(createdStorage.ID)
assert.NoError(t, err)
// Verify original sensitive data is still present in DB
tc.verifySensitiveData(t, storageFromDB)
// Verify non-sensitive fields were updated in DB
assert.Equal(t, updatedStorage.Name, storageFromDB.Name)
// Additional verification: Check via GET that data is still hidden
var finalRetrieved Storage
test_utils.MakeGetRequestAndUnmarshal(
t,
router,
fmt.Sprintf("/api/v1/storages/%s", createdStorage.ID.String()),
"Bearer "+owner.Token,
http.StatusOK,
&finalRetrieved,
)
tc.verifyHiddenData(t, &finalRetrieved)
})
}
}
func createRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
@@ -485,158 +870,3 @@ func deleteStorage(
http.StatusOK,
)
}
func Test_StorageSensitiveDataLifecycle_AllTypes(t *testing.T) {
testCases := []struct {
name string
storageType StorageType
createStorage func(workspaceID uuid.UUID) *Storage
updateStorage func(workspaceID uuid.UUID, storageID uuid.UUID) *Storage
verifySensitiveData func(t *testing.T, storage *Storage)
verifyHiddenData func(t *testing.T, storage *Storage)
}{
{
name: "S3 Storage",
storageType: StorageTypeS3,
createStorage: func(workspaceID uuid.UUID) *Storage {
return &Storage{
WorkspaceID: workspaceID,
Type: StorageTypeS3,
Name: "Test S3 Storage",
S3Storage: &s3_storage.S3Storage{
S3Bucket: "test-bucket",
S3Region: "us-east-1",
S3AccessKey: "original-access-key",
S3SecretKey: "original-secret-key",
S3Endpoint: "https://s3.amazonaws.com",
},
}
},
updateStorage: func(workspaceID uuid.UUID, storageID uuid.UUID) *Storage {
return &Storage{
ID: storageID,
WorkspaceID: workspaceID,
Type: StorageTypeS3,
Name: "Updated S3 Storage",
S3Storage: &s3_storage.S3Storage{
S3Bucket: "updated-bucket",
S3Region: "us-west-2",
S3AccessKey: "",
S3SecretKey: "",
S3Endpoint: "https://s3.us-west-2.amazonaws.com",
},
}
},
verifySensitiveData: func(t *testing.T, storage *Storage) {
assert.Equal(t, "original-access-key", storage.S3Storage.S3AccessKey)
assert.Equal(t, "original-secret-key", storage.S3Storage.S3SecretKey)
},
verifyHiddenData: func(t *testing.T, storage *Storage) {
assert.Equal(t, "", storage.S3Storage.S3AccessKey)
assert.Equal(t, "", storage.S3Storage.S3SecretKey)
},
},
{
name: "Local Storage",
storageType: StorageTypeLocal,
createStorage: func(workspaceID uuid.UUID) *Storage {
return &Storage{
WorkspaceID: workspaceID,
Type: StorageTypeLocal,
Name: "Test Local Storage",
LocalStorage: &local_storage.LocalStorage{},
}
},
updateStorage: func(workspaceID uuid.UUID, storageID uuid.UUID) *Storage {
return &Storage{
ID: storageID,
WorkspaceID: workspaceID,
Type: StorageTypeLocal,
Name: "Updated Local Storage",
LocalStorage: &local_storage.LocalStorage{},
}
},
verifySensitiveData: func(t *testing.T, storage *Storage) {
},
verifyHiddenData: func(t *testing.T, storage *Storage) {
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
router := createRouter()
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
// Phase 1: Create storage with sensitive data
initialStorage := tc.createStorage(workspace.ID)
var createdStorage Storage
test_utils.MakePostRequestAndUnmarshal(
t,
router,
"/api/v1/storages",
"Bearer "+owner.Token,
*initialStorage,
http.StatusOK,
&createdStorage,
)
assert.NotEmpty(t, createdStorage.ID)
assert.Equal(t, initialStorage.Name, createdStorage.Name)
// Phase 2: Read via service - sensitive data should be hidden
var retrievedStorage Storage
test_utils.MakeGetRequestAndUnmarshal(
t,
router,
fmt.Sprintf("/api/v1/storages/%s", createdStorage.ID.String()),
"Bearer "+owner.Token,
http.StatusOK,
&retrievedStorage,
)
tc.verifyHiddenData(t, &retrievedStorage)
assert.Equal(t, initialStorage.Name, retrievedStorage.Name)
// Phase 3: Update with non-sensitive changes only (sensitive fields empty)
updatedStorage := tc.updateStorage(workspace.ID, createdStorage.ID)
var updateResponse Storage
test_utils.MakePostRequestAndUnmarshal(
t,
router,
"/api/v1/storages",
"Bearer "+owner.Token,
*updatedStorage,
http.StatusOK,
&updateResponse,
)
// Verify non-sensitive fields were updated
assert.Equal(t, updatedStorage.Name, updateResponse.Name)
// Phase 4: Retrieve directly from repository to verify sensitive data preservation
repository := &StorageRepository{}
storageFromDB, err := repository.FindByID(createdStorage.ID)
assert.NoError(t, err)
// Verify original sensitive data is still present in DB
tc.verifySensitiveData(t, storageFromDB)
// Verify non-sensitive fields were updated in DB
assert.Equal(t, updatedStorage.Name, storageFromDB.Name)
// Additional verification: Check via GET that data is still hidden
var finalRetrieved Storage
test_utils.MakeGetRequestAndUnmarshal(
t,
router,
fmt.Sprintf("/api/v1/storages/%s", createdStorage.ID.String()),
"Bearer "+owner.Token,
http.StatusOK,
&finalRetrieved,
)
tc.verifyHiddenData(t, &finalRetrieved)
})
}
}

View File

@@ -3,6 +3,7 @@ package storages
import (
audit_logs "postgresus-backend/internal/features/audit_logs"
workspaces_services "postgresus-backend/internal/features/workspaces/services"
"postgresus-backend/internal/util/encryption"
)
var storageRepository = &StorageRepository{}
@@ -10,6 +11,7 @@ var storageService = &StorageService{
storageRepository,
workspaces_services.GetWorkspaceService(),
audit_logs.GetAuditLogService(),
encryption.GetFieldEncryptor(),
}
var storageController = &StorageController{
storageService,

View File

@@ -3,20 +3,28 @@ package storages
import (
"io"
"log/slog"
"postgresus-backend/internal/util/encryption"
"github.com/google/uuid"
)
type StorageFileSaver interface {
SaveFile(logger *slog.Logger, fileID uuid.UUID, file io.Reader) error
SaveFile(
encryptor encryption.FieldEncryptor,
logger *slog.Logger,
fileID uuid.UUID,
file io.Reader,
) error
GetFile(fileID uuid.UUID) (io.ReadCloser, error)
GetFile(encryptor encryption.FieldEncryptor, fileID uuid.UUID) (io.ReadCloser, error)
DeleteFile(fileID uuid.UUID) error
DeleteFile(encryptor encryption.FieldEncryptor, fileID uuid.UUID) error
Validate() error
Validate(encryptor encryption.FieldEncryptor) error
TestConnection() error
TestConnection(encryptor encryption.FieldEncryptor) error
HideSensitiveData()
EncryptSensitiveData(encryptor encryption.FieldEncryptor) error
}

View File

@@ -9,6 +9,7 @@ import (
local_storage "postgresus-backend/internal/features/storages/models/local"
nas_storage "postgresus-backend/internal/features/storages/models/nas"
s3_storage "postgresus-backend/internal/features/storages/models/s3"
"postgresus-backend/internal/util/encryption"
"github.com/google/uuid"
)
@@ -28,8 +29,13 @@ type Storage struct {
AzureBlobStorage *azure_blob_storage.AzureBlobStorage `json:"azureBlobStorage" gorm:"foreignKey:StorageID"`
}
func (s *Storage) SaveFile(logger *slog.Logger, fileID uuid.UUID, file io.Reader) error {
err := s.getSpecificStorage().SaveFile(logger, fileID, file)
func (s *Storage) SaveFile(
encryptor encryption.FieldEncryptor,
logger *slog.Logger,
fileID uuid.UUID,
file io.Reader,
) error {
err := s.getSpecificStorage().SaveFile(encryptor, logger, fileID, file)
if err != nil {
lastSaveError := err.Error()
s.LastSaveError = &lastSaveError
@@ -41,15 +47,18 @@ func (s *Storage) SaveFile(logger *slog.Logger, fileID uuid.UUID, file io.Reader
return nil
}
func (s *Storage) GetFile(fileID uuid.UUID) (io.ReadCloser, error) {
return s.getSpecificStorage().GetFile(fileID)
func (s *Storage) GetFile(
encryptor encryption.FieldEncryptor,
fileID uuid.UUID,
) (io.ReadCloser, error) {
return s.getSpecificStorage().GetFile(encryptor, fileID)
}
func (s *Storage) DeleteFile(fileID uuid.UUID) error {
return s.getSpecificStorage().DeleteFile(fileID)
func (s *Storage) DeleteFile(encryptor encryption.FieldEncryptor, fileID uuid.UUID) error {
return s.getSpecificStorage().DeleteFile(encryptor, fileID)
}
func (s *Storage) Validate() error {
func (s *Storage) Validate(encryptor encryption.FieldEncryptor) error {
if s.Type == "" {
return errors.New("storage type is required")
}
@@ -58,17 +67,21 @@ func (s *Storage) Validate() error {
return errors.New("storage name is required")
}
return s.getSpecificStorage().Validate()
return s.getSpecificStorage().Validate(encryptor)
}
func (s *Storage) TestConnection() error {
return s.getSpecificStorage().TestConnection()
func (s *Storage) TestConnection(encryptor encryption.FieldEncryptor) error {
return s.getSpecificStorage().TestConnection(encryptor)
}
func (s *Storage) HideSensitiveData() {
s.getSpecificStorage().HideSensitiveData()
}
func (s *Storage) EncryptSensitiveData(encryptor encryption.FieldEncryptor) error {
return s.getSpecificStorage().EncryptSensitiveData(encryptor)
}
func (s *Storage) Update(incoming *Storage) {
s.Name = incoming.Name
s.Type = incoming.Type

View File

@@ -13,6 +13,7 @@ import (
local_storage "postgresus-backend/internal/features/storages/models/local"
nas_storage "postgresus-backend/internal/features/storages/models/nas"
s3_storage "postgresus-backend/internal/features/storages/models/s3"
"postgresus-backend/internal/util/encryption"
"postgresus-backend/internal/util/logger"
"strconv"
"testing"
@@ -147,13 +148,15 @@ func Test_Storage_BasicOperations(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
encryptor := encryption.GetFieldEncryptor()
t.Run("Test_TestConnection_ConnectionSucceeds", func(t *testing.T) {
err := tc.storage.TestConnection()
err := tc.storage.TestConnection(encryptor)
assert.NoError(t, err, "TestConnection should succeed")
})
t.Run("Test_TestValidation_ValidationSucceeds", func(t *testing.T) {
err := tc.storage.Validate()
err := tc.storage.Validate(encryptor)
assert.NoError(t, err, "Validate should succeed")
})
@@ -163,10 +166,15 @@ func Test_Storage_BasicOperations(t *testing.T) {
fileID := uuid.New()
err = tc.storage.SaveFile(logger.GetLogger(), fileID, bytes.NewReader(fileData))
err = tc.storage.SaveFile(
encryptor,
logger.GetLogger(),
fileID,
bytes.NewReader(fileData),
)
require.NoError(t, err, "SaveFile should succeed")
file, err := tc.storage.GetFile(fileID)
file, err := tc.storage.GetFile(encryptor, fileID)
assert.NoError(t, err, "GetFile should succeed")
defer file.Close()
@@ -180,13 +188,18 @@ func Test_Storage_BasicOperations(t *testing.T) {
require.NoError(t, err, "Should be able to read test file")
fileID := uuid.New()
err = tc.storage.SaveFile(logger.GetLogger(), fileID, bytes.NewReader(fileData))
err = tc.storage.SaveFile(
encryptor,
logger.GetLogger(),
fileID,
bytes.NewReader(fileData),
)
require.NoError(t, err, "SaveFile should succeed")
err = tc.storage.DeleteFile(fileID)
err = tc.storage.DeleteFile(encryptor, fileID)
assert.NoError(t, err, "DeleteFile should succeed")
file, err := tc.storage.GetFile(fileID)
file, err := tc.storage.GetFile(encryptor, fileID)
assert.Error(t, err, "GetFile should fail for non-existent file")
if file != nil {
file.Close()
@@ -196,7 +209,7 @@ func Test_Storage_BasicOperations(t *testing.T) {
t.Run("Test_TestDeleteNonExistentFile_DoesNotError", func(t *testing.T) {
// Try to delete a non-existent file
nonExistentID := uuid.New()
err := tc.storage.DeleteFile(nonExistentID)
err := tc.storage.DeleteFile(encryptor, nonExistentID)
assert.NoError(t, err, "DeleteFile should not error for non-existent file")
})
})

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"log/slog"
"postgresus-backend/internal/util/encryption"
"strings"
"time"
@@ -37,8 +38,13 @@ func (s *AzureBlobStorage) TableName() string {
return "azure_blob_storages"
}
func (s *AzureBlobStorage) SaveFile(logger *slog.Logger, fileID uuid.UUID, file io.Reader) error {
client, err := s.getClient()
func (s *AzureBlobStorage) SaveFile(
encryptor encryption.FieldEncryptor,
logger *slog.Logger,
fileID uuid.UUID,
file io.Reader,
) error {
client, err := s.getClient(encryptor)
if err != nil {
return err
}
@@ -59,8 +65,11 @@ func (s *AzureBlobStorage) SaveFile(logger *slog.Logger, fileID uuid.UUID, file
return nil
}
func (s *AzureBlobStorage) GetFile(fileID uuid.UUID) (io.ReadCloser, error) {
client, err := s.getClient()
func (s *AzureBlobStorage) GetFile(
encryptor encryption.FieldEncryptor,
fileID uuid.UUID,
) (io.ReadCloser, error) {
client, err := s.getClient(encryptor)
if err != nil {
return nil, err
}
@@ -80,8 +89,8 @@ func (s *AzureBlobStorage) GetFile(fileID uuid.UUID) (io.ReadCloser, error) {
return response.Body, nil
}
func (s *AzureBlobStorage) DeleteFile(fileID uuid.UUID) error {
client, err := s.getClient()
func (s *AzureBlobStorage) DeleteFile(encryptor encryption.FieldEncryptor, fileID uuid.UUID) error {
client, err := s.getClient(encryptor)
if err != nil {
return err
}
@@ -105,7 +114,7 @@ func (s *AzureBlobStorage) DeleteFile(fileID uuid.UUID) error {
return nil
}
func (s *AzureBlobStorage) Validate() error {
func (s *AzureBlobStorage) Validate(encryptor encryption.FieldEncryptor) error {
if s.ContainerName == "" {
return errors.New("container name is required")
}
@@ -128,16 +137,11 @@ func (s *AzureBlobStorage) Validate() error {
return fmt.Errorf("invalid auth method: %s", s.AuthMethod)
}
_, err := s.getClient()
if err != nil {
return fmt.Errorf("invalid Azure Blob configuration: %w", err)
}
return nil
}
func (s *AzureBlobStorage) TestConnection() error {
client, err := s.getClient()
func (s *AzureBlobStorage) TestConnection(encryptor encryption.FieldEncryptor) error {
client, err := s.getClient(encryptor)
if err != nil {
return err
}
@@ -192,6 +196,26 @@ func (s *AzureBlobStorage) HideSensitiveData() {
s.AccountKey = ""
}
func (s *AzureBlobStorage) EncryptSensitiveData(encryptor encryption.FieldEncryptor) error {
var err error
if s.ConnectionString != "" {
s.ConnectionString, err = encryptor.Encrypt(s.StorageID, s.ConnectionString)
if err != nil {
return fmt.Errorf("failed to encrypt Azure connection string: %w", err)
}
}
if s.AccountKey != "" {
s.AccountKey, err = encryptor.Encrypt(s.StorageID, s.AccountKey)
if err != nil {
return fmt.Errorf("failed to encrypt Azure account key: %w", err)
}
}
return nil
}
func (s *AzureBlobStorage) Update(incoming *AzureBlobStorage) {
s.AuthMethod = incoming.AuthMethod
s.ContainerName = incoming.ContainerName
@@ -225,13 +249,18 @@ func (s *AzureBlobStorage) buildBlobName(fileName string) string {
return prefix + fileName
}
func (s *AzureBlobStorage) getClient() (*azblob.Client, error) {
func (s *AzureBlobStorage) getClient(encryptor encryption.FieldEncryptor) (*azblob.Client, error) {
var client *azblob.Client
var err error
switch s.AuthMethod {
case AuthMethodConnectionString:
client, err = azblob.NewClientFromConnectionString(s.ConnectionString, nil)
connectionString, decryptErr := encryptor.Decrypt(s.StorageID, s.ConnectionString)
if decryptErr != nil {
return nil, fmt.Errorf("failed to decrypt Azure connection string: %w", decryptErr)
}
client, err = azblob.NewClientFromConnectionString(connectionString, nil)
if err != nil {
return nil, fmt.Errorf(
"failed to create Azure Blob client from connection string: %w",
@@ -239,8 +268,13 @@ func (s *AzureBlobStorage) getClient() (*azblob.Client, error) {
)
}
case AuthMethodAccountKey:
accountKey, decryptErr := encryptor.Decrypt(s.StorageID, s.AccountKey)
if decryptErr != nil {
return nil, fmt.Errorf("failed to decrypt Azure account key: %w", decryptErr)
}
accountURL := s.buildAccountURL()
credential, credErr := azblob.NewSharedKeyCredential(s.AccountName, s.AccountKey)
credential, credErr := azblob.NewSharedKeyCredential(s.AccountName, accountKey)
if credErr != nil {
return nil, fmt.Errorf("failed to create Azure shared key credential: %w", credErr)
}

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"log/slog"
"postgresus-backend/internal/util/encryption"
"strings"
"time"
@@ -30,11 +31,12 @@ func (s *GoogleDriveStorage) TableName() string {
}
func (s *GoogleDriveStorage) SaveFile(
encryptor encryption.FieldEncryptor,
logger *slog.Logger,
fileID uuid.UUID,
file io.Reader,
) error {
return s.withRetryOnAuth(func(driveService *drive.Service) error {
return s.withRetryOnAuth(encryptor, func(driveService *drive.Service) error {
ctx := context.Background()
filename := fileID.String()
@@ -68,9 +70,12 @@ func (s *GoogleDriveStorage) SaveFile(
})
}
func (s *GoogleDriveStorage) GetFile(fileID uuid.UUID) (io.ReadCloser, error) {
func (s *GoogleDriveStorage) GetFile(
encryptor encryption.FieldEncryptor,
fileID uuid.UUID,
) (io.ReadCloser, error) {
var result io.ReadCloser
err := s.withRetryOnAuth(func(driveService *drive.Service) error {
err := s.withRetryOnAuth(encryptor, func(driveService *drive.Service) error {
folderID, err := s.findBackupsFolder(driveService)
if err != nil {
return fmt.Errorf("failed to find backups folder: %w", err)
@@ -93,8 +98,11 @@ func (s *GoogleDriveStorage) GetFile(fileID uuid.UUID) (io.ReadCloser, error) {
return result, err
}
func (s *GoogleDriveStorage) DeleteFile(fileID uuid.UUID) error {
return s.withRetryOnAuth(func(driveService *drive.Service) error {
func (s *GoogleDriveStorage) DeleteFile(
encryptor encryption.FieldEncryptor,
fileID uuid.UUID,
) error {
return s.withRetryOnAuth(encryptor, func(driveService *drive.Service) error {
ctx := context.Background()
folderID, err := s.findBackupsFolder(driveService)
if err != nil {
@@ -105,7 +113,7 @@ func (s *GoogleDriveStorage) DeleteFile(fileID uuid.UUID) error {
})
}
func (s *GoogleDriveStorage) Validate() error {
func (s *GoogleDriveStorage) Validate(encryptor encryption.FieldEncryptor) error {
switch {
case s.ClientID == "":
return errors.New("client ID is required")
@@ -115,7 +123,12 @@ func (s *GoogleDriveStorage) Validate() error {
return errors.New("token JSON is required")
}
// Also validate that the token JSON contains a refresh token
// Skip JSON validation if token is already encrypted
if strings.HasPrefix(s.TokenJSON, "enc:") {
return nil
}
// Validate that the token JSON contains a refresh token
var token oauth2.Token
if err := json.Unmarshal([]byte(s.TokenJSON), &token); err != nil {
return fmt.Errorf("invalid token JSON format: %w", err)
@@ -128,8 +141,8 @@ func (s *GoogleDriveStorage) Validate() error {
return nil
}
func (s *GoogleDriveStorage) TestConnection() error {
return s.withRetryOnAuth(func(driveService *drive.Service) error {
func (s *GoogleDriveStorage) TestConnection(encryptor encryption.FieldEncryptor) error {
return s.withRetryOnAuth(encryptor, func(driveService *drive.Service) error {
ctx := context.Background()
testFilename := "test-connection-" + uuid.New().String()
testData := []byte("test")
@@ -196,6 +209,26 @@ func (s *GoogleDriveStorage) HideSensitiveData() {
s.TokenJSON = ""
}
func (s *GoogleDriveStorage) EncryptSensitiveData(encryptor encryption.FieldEncryptor) error {
var err error
if s.ClientSecret != "" {
s.ClientSecret, err = encryptor.Encrypt(s.StorageID, s.ClientSecret)
if err != nil {
return fmt.Errorf("failed to encrypt Google Drive client secret: %w", err)
}
}
if s.TokenJSON != "" {
s.TokenJSON, err = encryptor.Encrypt(s.StorageID, s.TokenJSON)
if err != nil {
return fmt.Errorf("failed to encrypt Google Drive token JSON: %w", err)
}
}
return nil
}
func (s *GoogleDriveStorage) Update(incoming *GoogleDriveStorage) {
s.ClientID = incoming.ClientID
@@ -209,8 +242,11 @@ func (s *GoogleDriveStorage) Update(incoming *GoogleDriveStorage) {
}
// withRetryOnAuth executes the provided function with retry logic for authentication errors
func (s *GoogleDriveStorage) withRetryOnAuth(fn func(*drive.Service) error) error {
driveService, err := s.getDriveService()
func (s *GoogleDriveStorage) withRetryOnAuth(
encryptor encryption.FieldEncryptor,
fn func(*drive.Service) error,
) error {
driveService, err := s.getDriveService(encryptor)
if err != nil {
return err
}
@@ -220,7 +256,7 @@ func (s *GoogleDriveStorage) withRetryOnAuth(fn func(*drive.Service) error) erro
// Try to refresh token and retry once
fmt.Printf("Google Drive auth error detected, attempting token refresh: %v\n", err)
if refreshErr := s.refreshToken(); refreshErr != nil {
if refreshErr := s.refreshToken(encryptor); refreshErr != nil {
// If refresh fails, return a more helpful error message
if strings.Contains(refreshErr.Error(), "invalid_grant") ||
strings.Contains(refreshErr.Error(), "refresh token") {
@@ -237,7 +273,7 @@ func (s *GoogleDriveStorage) withRetryOnAuth(fn func(*drive.Service) error) erro
fmt.Printf("Token refresh successful, retrying operation\n")
// Get new service with refreshed token
driveService, err = s.getDriveService()
driveService, err = s.getDriveService(encryptor)
if err != nil {
return fmt.Errorf("failed to create service after token refresh: %w", err)
}
@@ -268,13 +304,24 @@ func (s *GoogleDriveStorage) isAuthError(err error) bool {
}
// refreshToken refreshes the OAuth2 token and updates the TokenJSON field
func (s *GoogleDriveStorage) refreshToken() error {
if err := s.Validate(); err != nil {
func (s *GoogleDriveStorage) refreshToken(encryptor encryption.FieldEncryptor) error {
if err := s.Validate(encryptor); err != nil {
return err
}
// Decrypt credentials before use
clientSecret, err := encryptor.Decrypt(s.StorageID, s.ClientSecret)
if err != nil {
return fmt.Errorf("failed to decrypt Google Drive client secret: %w", err)
}
tokenJSON, err := encryptor.Decrypt(s.StorageID, s.TokenJSON)
if err != nil {
return fmt.Errorf("failed to decrypt Google Drive token JSON: %w", err)
}
var token oauth2.Token
if err := json.Unmarshal([]byte(s.TokenJSON), &token); err != nil {
if err := json.Unmarshal([]byte(tokenJSON), &token); err != nil {
return fmt.Errorf("invalid token JSON: %w", err)
}
@@ -289,12 +336,12 @@ func (s *GoogleDriveStorage) refreshToken() error {
token.Expiry)
// Debug: Print the full token JSON structure (sensitive data masked)
fmt.Printf("Original token JSON structure: %s\n", maskSensitiveData(s.TokenJSON))
fmt.Printf("Original token JSON structure: %s\n", maskSensitiveData(tokenJSON))
ctx := context.Background()
cfg := &oauth2.Config{
ClientID: s.ClientID,
ClientSecret: s.ClientSecret,
ClientSecret: clientSecret,
Endpoint: google.Endpoint,
Scopes: []string{"https://www.googleapis.com/auth/drive.file"},
}
@@ -330,7 +377,7 @@ func (s *GoogleDriveStorage) refreshToken() error {
newToken.RefreshToken = token.RefreshToken
}
// Update the stored token JSON
// Update the stored token JSON (keep as plaintext in memory, encryption happens on save)
newTokenJSON, err := json.Marshal(newToken)
if err != nil {
return fmt.Errorf("failed to marshal refreshed token: %w", err)
@@ -368,13 +415,26 @@ func truncateString(s string, maxLen int) string {
return s[:maxLen]
}
func (s *GoogleDriveStorage) getDriveService() (*drive.Service, error) {
if err := s.Validate(); err != nil {
func (s *GoogleDriveStorage) getDriveService(
encryptor encryption.FieldEncryptor,
) (*drive.Service, error) {
if err := s.Validate(encryptor); err != nil {
return nil, err
}
// Decrypt credentials before use
clientSecret, err := encryptor.Decrypt(s.StorageID, s.ClientSecret)
if err != nil {
return nil, fmt.Errorf("failed to decrypt Google Drive client secret: %w", err)
}
tokenJSON, err := encryptor.Decrypt(s.StorageID, s.TokenJSON)
if err != nil {
return nil, fmt.Errorf("failed to decrypt Google Drive token JSON: %w", err)
}
var token oauth2.Token
if err := json.Unmarshal([]byte(s.TokenJSON), &token); err != nil {
if err := json.Unmarshal([]byte(tokenJSON), &token); err != nil {
return nil, fmt.Errorf("invalid token JSON: %w", err)
}
@@ -382,7 +442,7 @@ func (s *GoogleDriveStorage) getDriveService() (*drive.Service, error) {
cfg := &oauth2.Config{
ClientID: s.ClientID,
ClientSecret: s.ClientSecret,
ClientSecret: clientSecret,
Endpoint: google.Endpoint,
Scopes: []string{"https://www.googleapis.com/auth/drive.file"},
}

View File

@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"postgresus-backend/internal/config"
"postgresus-backend/internal/util/encryption"
files_utils "postgresus-backend/internal/util/files"
"github.com/google/uuid"
@@ -23,7 +24,12 @@ func (l *LocalStorage) TableName() string {
return "local_storages"
}
func (l *LocalStorage) SaveFile(logger *slog.Logger, fileID uuid.UUID, file io.Reader) error {
func (l *LocalStorage) SaveFile(
encryptor encryption.FieldEncryptor,
logger *slog.Logger,
fileID uuid.UUID,
file io.Reader,
) error {
logger.Info("Starting to save file to local storage", "fileId", fileID.String())
err := files_utils.EnsureDirectories([]string{
@@ -107,7 +113,10 @@ func (l *LocalStorage) SaveFile(logger *slog.Logger, fileID uuid.UUID, file io.R
return nil
}
func (l *LocalStorage) GetFile(fileID uuid.UUID) (io.ReadCloser, error) {
func (l *LocalStorage) GetFile(
encryptor encryption.FieldEncryptor,
fileID uuid.UUID,
) (io.ReadCloser, error) {
filePath := filepath.Join(config.GetEnv().DataFolder, fileID.String())
if _, err := os.Stat(filePath); os.IsNotExist(err) {
@@ -122,7 +131,7 @@ func (l *LocalStorage) GetFile(fileID uuid.UUID) (io.ReadCloser, error) {
return file, nil
}
func (l *LocalStorage) DeleteFile(fileID uuid.UUID) error {
func (l *LocalStorage) DeleteFile(encryptor encryption.FieldEncryptor, fileID uuid.UUID) error {
filePath := filepath.Join(config.GetEnv().DataFolder, fileID.String())
if _, err := os.Stat(filePath); os.IsNotExist(err) {
@@ -136,11 +145,11 @@ func (l *LocalStorage) DeleteFile(fileID uuid.UUID) error {
return nil
}
func (l *LocalStorage) Validate() error {
func (l *LocalStorage) Validate(encryptor encryption.FieldEncryptor) error {
return nil
}
func (l *LocalStorage) TestConnection() error {
func (l *LocalStorage) TestConnection(encryptor encryption.FieldEncryptor) error {
testFile := filepath.Join(config.GetEnv().TempFolder, "test_connection")
f, err := os.Create(testFile)
if err != nil {
@@ -160,5 +169,9 @@ func (l *LocalStorage) TestConnection() error {
func (l *LocalStorage) HideSensitiveData() {
}
func (l *LocalStorage) EncryptSensitiveData(encryptor encryption.FieldEncryptor) error {
return nil
}
func (l *LocalStorage) Update(incoming *LocalStorage) {
}

View File

@@ -8,6 +8,7 @@ import (
"log/slog"
"net"
"path/filepath"
"postgresus-backend/internal/util/encryption"
"strings"
"time"
@@ -31,10 +32,15 @@ func (n *NASStorage) TableName() string {
return "nas_storages"
}
func (n *NASStorage) SaveFile(logger *slog.Logger, fileID uuid.UUID, file io.Reader) error {
func (n *NASStorage) SaveFile(
encryptor encryption.FieldEncryptor,
logger *slog.Logger,
fileID uuid.UUID,
file io.Reader,
) error {
logger.Info("Starting to save file to NAS storage", "fileId", fileID.String(), "host", n.Host)
session, err := n.createSession()
session, err := n.createSession(encryptor)
if err != nil {
logger.Error("Failed to create NAS session", "fileId", fileID.String(), "error", err)
return fmt.Errorf("failed to create NAS session: %w", err)
@@ -131,8 +137,11 @@ func (n *NASStorage) SaveFile(logger *slog.Logger, fileID uuid.UUID, file io.Rea
return nil
}
func (n *NASStorage) GetFile(fileID uuid.UUID) (io.ReadCloser, error) {
session, err := n.createSession()
func (n *NASStorage) GetFile(
encryptor encryption.FieldEncryptor,
fileID uuid.UUID,
) (io.ReadCloser, error) {
session, err := n.createSession(encryptor)
if err != nil {
return nil, fmt.Errorf("failed to create NAS session: %w", err)
}
@@ -168,8 +177,8 @@ func (n *NASStorage) GetFile(fileID uuid.UUID) (io.ReadCloser, error) {
}, nil
}
func (n *NASStorage) DeleteFile(fileID uuid.UUID) error {
session, err := n.createSession()
func (n *NASStorage) DeleteFile(encryptor encryption.FieldEncryptor, fileID uuid.UUID) error {
session, err := n.createSession(encryptor)
if err != nil {
return fmt.Errorf("failed to create NAS session: %w", err)
}
@@ -202,7 +211,7 @@ func (n *NASStorage) DeleteFile(fileID uuid.UUID) error {
return nil
}
func (n *NASStorage) Validate() error {
func (n *NASStorage) Validate(encryptor encryption.FieldEncryptor) error {
if n.Host == "" {
return errors.New("NAS host is required")
}
@@ -219,12 +228,11 @@ func (n *NASStorage) Validate() error {
return errors.New("NAS port must be between 1 and 65535")
}
// Test the configuration by creating a session
return n.TestConnection()
return nil
}
func (n *NASStorage) TestConnection() error {
session, err := n.createSession()
func (n *NASStorage) TestConnection(encryptor encryption.FieldEncryptor) error {
session, err := n.createSession(encryptor)
if err != nil {
return fmt.Errorf("failed to connect to NAS: %w", err)
}
@@ -255,6 +263,18 @@ func (n *NASStorage) HideSensitiveData() {
n.Password = ""
}
func (n *NASStorage) EncryptSensitiveData(encryptor encryption.FieldEncryptor) error {
if n.Password != "" {
encrypted, err := encryptor.Encrypt(n.StorageID, n.Password)
if err != nil {
return fmt.Errorf("failed to encrypt NAS password: %w", err)
}
n.Password = encrypted
}
return nil
}
func (n *NASStorage) Update(incoming *NASStorage) {
n.Host = incoming.Host
n.Port = incoming.Port
@@ -269,18 +289,25 @@ func (n *NASStorage) Update(incoming *NASStorage) {
}
}
func (n *NASStorage) createSession() (*smb2.Session, error) {
func (n *NASStorage) createSession(encryptor encryption.FieldEncryptor) (*smb2.Session, error) {
// Create connection with timeout
conn, err := n.createConnection()
if err != nil {
return nil, err
}
// Decrypt password before use
password, err := encryptor.Decrypt(n.StorageID, n.Password)
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("failed to decrypt NAS password: %w", err)
}
// Create SMB2 dialer
d := &smb2.Dialer{
Initiator: &smb2.NTLMInitiator{
User: n.Username,
Password: n.Password,
Password: password,
Domain: n.Domain,
},
}

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"log/slog"
"postgresus-backend/internal/util/encryption"
"strings"
"time"
@@ -31,8 +32,13 @@ func (s *S3Storage) TableName() string {
return "s3_storages"
}
func (s *S3Storage) SaveFile(logger *slog.Logger, fileID uuid.UUID, file io.Reader) error {
client, err := s.getClient()
func (s *S3Storage) SaveFile(
encryptor encryption.FieldEncryptor,
logger *slog.Logger,
fileID uuid.UUID,
file io.Reader,
) error {
client, err := s.getClient(encryptor)
if err != nil {
return err
}
@@ -55,8 +61,11 @@ func (s *S3Storage) SaveFile(logger *slog.Logger, fileID uuid.UUID, file io.Read
return nil
}
func (s *S3Storage) GetFile(fileID uuid.UUID) (io.ReadCloser, error) {
client, err := s.getClient()
func (s *S3Storage) GetFile(
encryptor encryption.FieldEncryptor,
fileID uuid.UUID,
) (io.ReadCloser, error) {
client, err := s.getClient(encryptor)
if err != nil {
return nil, err
}
@@ -91,8 +100,8 @@ func (s *S3Storage) GetFile(fileID uuid.UUID) (io.ReadCloser, error) {
return object, nil
}
func (s *S3Storage) DeleteFile(fileID uuid.UUID) error {
client, err := s.getClient()
func (s *S3Storage) DeleteFile(encryptor encryption.FieldEncryptor, fileID uuid.UUID) error {
client, err := s.getClient(encryptor)
if err != nil {
return err
}
@@ -113,7 +122,7 @@ func (s *S3Storage) DeleteFile(fileID uuid.UUID) error {
return nil
}
func (s *S3Storage) Validate() error {
func (s *S3Storage) Validate(encryptor encryption.FieldEncryptor) error {
if s.S3Bucket == "" {
return errors.New("S3 bucket is required")
}
@@ -124,17 +133,11 @@ func (s *S3Storage) Validate() error {
return errors.New("S3 secret key is required")
}
// Try to create a client to validate the configuration
_, err := s.getClient()
if err != nil {
return fmt.Errorf("invalid S3 configuration: %w", err)
}
return nil
}
func (s *S3Storage) TestConnection() error {
client, err := s.getClient()
func (s *S3Storage) TestConnection(encryptor encryption.FieldEncryptor) error {
client, err := s.getClient(encryptor)
if err != nil {
return err
}
@@ -195,6 +198,26 @@ func (s *S3Storage) HideSensitiveData() {
s.S3SecretKey = ""
}
func (s *S3Storage) EncryptSensitiveData(encryptor encryption.FieldEncryptor) error {
var err error
if s.S3AccessKey != "" {
s.S3AccessKey, err = encryptor.Encrypt(s.StorageID, s.S3AccessKey)
if err != nil {
return fmt.Errorf("failed to encrypt S3 access key: %w", err)
}
}
if s.S3SecretKey != "" {
s.S3SecretKey, err = encryptor.Encrypt(s.StorageID, s.S3SecretKey)
if err != nil {
return fmt.Errorf("failed to encrypt S3 secret key: %w", err)
}
}
return nil
}
func (s *S3Storage) Update(incoming *S3Storage) {
s.S3Bucket = incoming.S3Bucket
s.S3Region = incoming.S3Region
@@ -228,7 +251,7 @@ func (s *S3Storage) buildObjectKey(fileName string) string {
return prefix + fileName
}
func (s *S3Storage) getClient() (*minio.Client, error) {
func (s *S3Storage) getClient(encryptor encryption.FieldEncryptor) (*minio.Client, error) {
endpoint := s.S3Endpoint
useSSL := true
@@ -244,6 +267,17 @@ func (s *S3Storage) getClient() (*minio.Client, error) {
endpoint = fmt.Sprintf("s3.%s.amazonaws.com", s.S3Region)
}
// Decrypt credentials before use
accessKey, err := encryptor.Decrypt(s.StorageID, s.S3AccessKey)
if err != nil {
return nil, fmt.Errorf("failed to decrypt S3 access key: %w", err)
}
secretKey, err := encryptor.Decrypt(s.StorageID, s.S3SecretKey)
if err != nil {
return nil, fmt.Errorf("failed to decrypt S3 secret key: %w", err)
}
// Configure bucket lookup strategy
bucketLookup := minio.BucketLookupAuto
if s.S3UseVirtualHostedStyle {
@@ -252,7 +286,7 @@ func (s *S3Storage) getClient() (*minio.Client, error) {
// Initialize the MinIO client
minioClient, err := minio.New(endpoint, &minio.Options{
Creds: credentials.NewStaticV4(s.S3AccessKey, s.S3SecretKey, ""),
Creds: credentials.NewStaticV4(accessKey, secretKey, ""),
Secure: useSSL,
Region: s.S3Region,
BucketLookup: bucketLookup,

View File

@@ -7,6 +7,7 @@ import (
audit_logs "postgresus-backend/internal/features/audit_logs"
users_models "postgresus-backend/internal/features/users/models"
workspaces_services "postgresus-backend/internal/features/workspaces/services"
"postgresus-backend/internal/util/encryption"
"github.com/google/uuid"
)
@@ -15,6 +16,7 @@ type StorageService struct {
storageRepository *StorageRepository
workspaceService *workspaces_services.WorkspaceService
auditLogService *audit_logs.AuditLogService
fieldEncryptor encryption.FieldEncryptor
}
func (s *StorageService) SaveStorage(
@@ -44,7 +46,11 @@ func (s *StorageService) SaveStorage(
existingStorage.Update(storage)
if err := existingStorage.Validate(); err != nil {
if err := existingStorage.EncryptSensitiveData(s.fieldEncryptor); err != nil {
return err
}
if err := existingStorage.Validate(s.fieldEncryptor); err != nil {
return err
}
@@ -61,7 +67,11 @@ func (s *StorageService) SaveStorage(
} else {
storage.WorkspaceID = workspaceID
if err := storage.Validate(); err != nil {
if err := storage.EncryptSensitiveData(s.fieldEncryptor); err != nil {
return err
}
if err := storage.Validate(s.fieldEncryptor); err != nil {
return err
}
@@ -174,7 +184,7 @@ func (s *StorageService) TestStorageConnection(
return errors.New("insufficient permissions to test storage in this workspace")
}
err = storage.TestConnection()
err = storage.TestConnection(s.fieldEncryptor)
if err != nil {
lastSaveError := err.Error()
storage.LastSaveError = &lastSaveError
@@ -207,7 +217,7 @@ func (s *StorageService) TestStorageConnectionDirect(
existingStorage.Update(storage)
if err := existingStorage.Validate(); err != nil {
if err := existingStorage.Validate(s.fieldEncryptor); err != nil {
return err
}
@@ -216,7 +226,7 @@ func (s *StorageService) TestStorageConnectionDirect(
usingStorage = storage
}
return usingStorage.TestConnection()
return usingStorage.TestConnection(s.fieldEncryptor)
}
func (s *StorageService) GetStorageByID(

View File

@@ -1,31 +1,36 @@
package tests
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"postgresus-backend/internal/config"
"postgresus-backend/internal/features/backups/backups"
usecases_postgresql_backup "postgresus-backend/internal/features/backups/backups/usecases/postgresql"
backups_config "postgresus-backend/internal/features/backups/config"
"postgresus-backend/internal/features/databases"
pgtypes "postgresus-backend/internal/features/databases/databases/postgresql"
"postgresus-backend/internal/features/intervals"
"postgresus-backend/internal/features/restores/models"
usecases_postgresql_restore "postgresus-backend/internal/features/restores/usecases/postgresql"
"postgresus-backend/internal/features/storages"
local_storage "postgresus-backend/internal/features/storages/models/local"
"postgresus-backend/internal/util/period"
"postgresus-backend/internal/util/tools"
"strconv"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"github.com/stretchr/testify/assert"
"postgresus-backend/internal/config"
"postgresus-backend/internal/features/backups/backups"
backups_config "postgresus-backend/internal/features/backups/config"
"postgresus-backend/internal/features/databases"
pgtypes "postgresus-backend/internal/features/databases/databases/postgresql"
"postgresus-backend/internal/features/restores"
restores_enums "postgresus-backend/internal/features/restores/enums"
restores_models "postgresus-backend/internal/features/restores/models"
"postgresus-backend/internal/features/storages"
users_enums "postgresus-backend/internal/features/users/enums"
users_testing "postgresus-backend/internal/features/users/testing"
workspaces_controllers "postgresus-backend/internal/features/workspaces/controllers"
workspaces_testing "postgresus-backend/internal/features/workspaces/testing"
test_utils "postgresus-backend/internal/util/testing"
"postgresus-backend/internal/util/tools"
)
const createAndFillTableQuery = `
@@ -61,7 +66,6 @@ type TestDataItem struct {
CreatedAt time.Time `db:"created_at"`
}
// Main test functions for each PostgreSQL version
func Test_BackupAndRestorePostgresql_RestoreIsSuccesful(t *testing.T) {
env := config.GetEnv()
cases := []struct {
@@ -110,143 +114,7 @@ func Test_BackupAndRestorePostgresqlWithEncryption_RestoreIsSuccessful(t *testin
}
}
func testBackupRestoreWithEncryptionForVersion(t *testing.T, pgVersion string, port string) {
// Connect to pre-configured PostgreSQL container
container, err := connectToPostgresContainer(pgVersion, port)
assert.NoError(t, err)
defer func() {
if container.DB != nil {
container.DB.Close()
}
}()
_, err = container.DB.Exec(createAndFillTableQuery)
assert.NoError(t, err)
// Prepare data for backup
backupID := uuid.New()
pgVersionEnum := tools.GetPostgresqlVersionEnum(pgVersion)
backupDb := &databases.Database{
ID: uuid.New(),
Type: databases.DatabaseTypePostgres,
Name: "Test Database",
Postgresql: &pgtypes.PostgresqlDatabase{
Version: pgVersionEnum,
Host: container.Host,
Port: container.Port,
Username: container.Username,
Password: container.Password,
Database: &container.Database,
IsHttps: false,
},
}
storageID := uuid.New()
backupConfig := &backups_config.BackupConfig{
DatabaseID: backupDb.ID,
IsBackupsEnabled: true,
StorePeriod: period.PeriodDay,
BackupInterval: &intervals.Interval{Interval: intervals.IntervalDaily},
StorageID: &storageID,
CpuCount: 1,
Encryption: backups_config.BackupEncryptionEncrypted,
}
storage := &storages.Storage{
WorkspaceID: uuid.New(),
Type: storages.StorageTypeLocal,
Name: "Test Storage",
LocalStorage: &local_storage.LocalStorage{},
}
// Make backup
progressTracker := func(completedMBs float64) {}
metadata, err := usecases_postgresql_backup.GetCreatePostgresqlBackupUsecase().Execute(
context.Background(),
backupID,
backupConfig,
backupDb,
storage,
progressTracker,
)
assert.NoError(t, err)
assert.NotNil(t, metadata)
// Verify encryption metadata is set
assert.Equal(t, backups_config.BackupEncryptionEncrypted, metadata.Encryption)
assert.NotNil(t, metadata.EncryptionSalt)
assert.NotNil(t, metadata.EncryptionIV)
// Create new database
newDBName := "restoreddb_encrypted"
_, err = container.DB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s;", newDBName))
assert.NoError(t, err)
_, err = container.DB.Exec(fmt.Sprintf("CREATE DATABASE %s;", newDBName))
assert.NoError(t, err)
// Connect to the new database
newDSN := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
container.Host, container.Port, container.Username, container.Password, newDBName)
newDB, err := sqlx.Connect("postgres", newDSN)
assert.NoError(t, err)
defer newDB.Close()
// Setup data for restore with encryption metadata
completedBackup := &backups.Backup{
ID: backupID,
DatabaseID: backupDb.ID,
StorageID: storage.ID,
Status: backups.BackupStatusCompleted,
CreatedAt: time.Now().UTC(),
EncryptionSalt: metadata.EncryptionSalt,
EncryptionIV: metadata.EncryptionIV,
Encryption: metadata.Encryption,
}
restoreID := uuid.New()
restore := models.Restore{
ID: restoreID,
Backup: completedBackup,
Postgresql: &pgtypes.PostgresqlDatabase{
Version: pgVersionEnum,
Host: container.Host,
Port: container.Port,
Username: container.Username,
Password: container.Password,
Database: &newDBName,
IsHttps: false,
},
}
// Restore the encrypted backup
restoreBackupUC := usecases_postgresql_restore.GetRestorePostgresqlBackupUsecase()
err = restoreBackupUC.Execute(backupDb, backupConfig, restore, completedBackup, storage)
assert.NoError(t, err)
// Verify restored table exists
var tableExists bool
err = newDB.Get(
&tableExists,
"SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'test_data')",
)
assert.NoError(t, err)
assert.True(t, tableExists, "Table 'test_data' should exist in restored database")
// Verify data integrity
verifyDataIntegrity(t, container.DB, newDB)
// Clean up the backup file after the test
err = os.Remove(filepath.Join(config.GetEnv().DataFolder, backupID.String()))
if err != nil {
t.Logf("Warning: Failed to delete backup file: %v", err)
}
}
// Run a test for a specific PostgreSQL version
func testBackupRestoreForVersion(t *testing.T, pgVersion string, port string) {
// Connect to pre-configured PostgreSQL container
container, err := connectToPostgresContainer(pgVersion, port)
assert.NoError(t, err)
defer func() {
@@ -258,55 +126,30 @@ func testBackupRestoreForVersion(t *testing.T, pgVersion string, port string) {
_, err = container.DB.Exec(createAndFillTableQuery)
assert.NoError(t, err)
// Prepare data for backup
backupID := uuid.New()
router := createTestRouter()
user := users_testing.CreateTestUser(users_enums.UserRoleMember)
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", user, router)
storage := storages.CreateTestStorage(workspace.ID)
pgVersionEnum := tools.GetPostgresqlVersionEnum(pgVersion)
backupDb := &databases.Database{
ID: uuid.New(),
Type: databases.DatabaseTypePostgres,
Name: "Test Database",
Postgresql: &pgtypes.PostgresqlDatabase{
Version: pgVersionEnum,
Host: container.Host,
Port: container.Port,
Username: container.Username,
Password: container.Password,
Database: &container.Database,
IsHttps: false,
},
}
storageID := uuid.New()
backupConfig := &backups_config.BackupConfig{
DatabaseID: backupDb.ID,
IsBackupsEnabled: true,
StorePeriod: period.PeriodDay,
BackupInterval: &intervals.Interval{Interval: intervals.IntervalDaily},
StorageID: &storageID,
CpuCount: 1,
}
storage := &storages.Storage{
WorkspaceID: uuid.New(),
Type: storages.StorageTypeLocal,
Name: "Test Storage",
LocalStorage: &local_storage.LocalStorage{},
}
// Make backup
progressTracker := func(completedMBs float64) {}
_, err = usecases_postgresql_backup.GetCreatePostgresqlBackupUsecase().Execute(
context.Background(),
backupID,
backupConfig,
backupDb,
storage,
progressTracker,
database := createDatabaseViaAPI(
t, router, "Test Database", workspace.ID,
pgVersionEnum, container.Host, container.Port,
container.Username, container.Password, container.Database,
user.Token,
)
assert.NoError(t, err)
// Create new database
enableBackupsViaAPI(
t, router, database.ID, storage.ID,
backups_config.BackupEncryptionNone, user.Token,
)
createBackupViaAPI(t, router, database.ID, user.Token)
backup := waitForBackupCompletion(t, router, database.ID, user.Token, 5*time.Minute)
assert.Equal(t, backups.BackupStatusCompleted, backup.Status)
newDBName := "restoreddb"
_, err = container.DB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s;", newDBName))
assert.NoError(t, err)
@@ -314,43 +157,22 @@ func testBackupRestoreForVersion(t *testing.T, pgVersion string, port string) {
_, err = container.DB.Exec(fmt.Sprintf("CREATE DATABASE %s;", newDBName))
assert.NoError(t, err)
// Connect to the new database
newDSN := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
container.Host, container.Port, container.Username, container.Password, newDBName)
newDB, err := sqlx.Connect("postgres", newDSN)
assert.NoError(t, err)
defer newDB.Close()
// Setup data for restore
completedBackup := &backups.Backup{
ID: backupID,
DatabaseID: backupDb.ID,
StorageID: storage.ID,
Status: backups.BackupStatusCompleted,
CreatedAt: time.Now().UTC(),
}
createRestoreViaAPI(
t, router, backup.ID, pgVersionEnum,
container.Host, container.Port,
container.Username, container.Password, newDBName,
user.Token,
)
restoreID := uuid.New()
restore := models.Restore{
ID: restoreID,
Backup: completedBackup,
Postgresql: &pgtypes.PostgresqlDatabase{
Version: pgVersionEnum,
Host: container.Host,
Port: container.Port,
Username: container.Username,
Password: container.Password,
Database: &newDBName,
IsHttps: false,
},
}
restore := waitForRestoreCompletion(t, router, backup.ID, user.Token, 5*time.Minute)
assert.Equal(t, restores_enums.RestoreStatusCompleted, restore.Status)
// Restore the backup
restoreBackupUC := usecases_postgresql_restore.GetRestorePostgresqlBackupUsecase()
err = restoreBackupUC.Execute(backupDb, backupConfig, restore, completedBackup, storage)
assert.NoError(t, err)
// Verify restored table exists
var tableExists bool
err = newDB.Get(
&tableExists,
@@ -359,17 +181,329 @@ func testBackupRestoreForVersion(t *testing.T, pgVersion string, port string) {
assert.NoError(t, err)
assert.True(t, tableExists, "Table 'test_data' should exist in restored database")
// Verify data integrity
verifyDataIntegrity(t, container.DB, newDB)
// Clean up the backup file after the test
err = os.Remove(filepath.Join(config.GetEnv().DataFolder, backupID.String()))
err = os.Remove(filepath.Join(config.GetEnv().DataFolder, backup.ID.String()))
if err != nil {
t.Logf("Warning: Failed to delete backup file: %v", err)
}
test_utils.MakeDeleteRequest(
t,
router,
"/api/v1/databases/"+database.ID.String(),
"Bearer "+user.Token,
http.StatusNoContent,
)
storages.RemoveTestStorage(storage.ID)
workspaces_testing.RemoveTestWorkspace(workspace, router)
}
func testBackupRestoreWithEncryptionForVersion(t *testing.T, pgVersion string, port string) {
container, err := connectToPostgresContainer(pgVersion, port)
assert.NoError(t, err)
defer func() {
if container.DB != nil {
container.DB.Close()
}
}()
_, err = container.DB.Exec(createAndFillTableQuery)
assert.NoError(t, err)
router := createTestRouter()
user := users_testing.CreateTestUser(users_enums.UserRoleMember)
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", user, router)
storage := storages.CreateTestStorage(workspace.ID)
pgVersionEnum := tools.GetPostgresqlVersionEnum(pgVersion)
database := createDatabaseViaAPI(
t, router, "Test Database", workspace.ID,
pgVersionEnum, container.Host, container.Port,
container.Username, container.Password, container.Database,
user.Token,
)
enableBackupsViaAPI(
t, router, database.ID, storage.ID,
backups_config.BackupEncryptionEncrypted, user.Token,
)
createBackupViaAPI(t, router, database.ID, user.Token)
backup := waitForBackupCompletion(t, router, database.ID, user.Token, 5*time.Minute)
assert.Equal(t, backups.BackupStatusCompleted, backup.Status)
assert.Equal(t, backups_config.BackupEncryptionEncrypted, backup.Encryption)
newDBName := "restoreddb_encrypted"
_, err = container.DB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s;", newDBName))
assert.NoError(t, err)
_, err = container.DB.Exec(fmt.Sprintf("CREATE DATABASE %s;", newDBName))
assert.NoError(t, err)
newDSN := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
container.Host, container.Port, container.Username, container.Password, newDBName)
newDB, err := sqlx.Connect("postgres", newDSN)
assert.NoError(t, err)
defer newDB.Close()
createRestoreViaAPI(
t, router, backup.ID, pgVersionEnum,
container.Host, container.Port,
container.Username, container.Password, newDBName,
user.Token,
)
restore := waitForRestoreCompletion(t, router, backup.ID, user.Token, 5*time.Minute)
assert.Equal(t, restores_enums.RestoreStatusCompleted, restore.Status)
var tableExists bool
err = newDB.Get(
&tableExists,
"SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'test_data')",
)
assert.NoError(t, err)
assert.True(t, tableExists, "Table 'test_data' should exist in restored database")
verifyDataIntegrity(t, container.DB, newDB)
err = os.Remove(filepath.Join(config.GetEnv().DataFolder, backup.ID.String()))
if err != nil {
t.Logf("Warning: Failed to delete backup file: %v", err)
}
test_utils.MakeDeleteRequest(
t,
router,
"/api/v1/databases/"+database.ID.String(),
"Bearer "+user.Token,
http.StatusNoContent,
)
storages.RemoveTestStorage(storage.ID)
workspaces_testing.RemoveTestWorkspace(workspace, router)
}
func createTestRouter() *gin.Engine {
router := workspaces_testing.CreateTestRouter(
workspaces_controllers.GetWorkspaceController(),
workspaces_controllers.GetMembershipController(),
databases.GetDatabaseController(),
backups_config.GetBackupConfigController(),
backups.GetBackupController(),
restores.GetRestoreController(),
)
return router
}
func waitForBackupCompletion(
t *testing.T,
router *gin.Engine,
databaseID uuid.UUID,
token string,
timeout time.Duration,
) *backups.Backup {
startTime := time.Now()
pollInterval := 500 * time.Millisecond
for {
if time.Since(startTime) > timeout {
t.Fatalf("Timeout waiting for backup completion after %v", timeout)
}
var response backups.GetBackupsResponse
test_utils.MakeGetRequestAndUnmarshal(
t,
router,
fmt.Sprintf("/api/v1/backups?database_id=%s&limit=1", databaseID.String()),
"Bearer "+token,
http.StatusOK,
&response,
)
if len(response.Backups) > 0 {
backup := response.Backups[0]
if backup.Status == backups.BackupStatusCompleted {
return backup
}
if backup.Status == backups.BackupStatusFailed {
t.Fatalf("Backup failed: %v", backup.FailMessage)
}
}
time.Sleep(pollInterval)
}
}
func waitForRestoreCompletion(
t *testing.T,
router *gin.Engine,
backupID uuid.UUID,
token string,
timeout time.Duration,
) *restores_models.Restore {
startTime := time.Now()
pollInterval := 500 * time.Millisecond
for {
if time.Since(startTime) > timeout {
t.Fatalf("Timeout waiting for restore completion after %v", timeout)
}
var restores []*restores_models.Restore
test_utils.MakeGetRequestAndUnmarshal(
t,
router,
fmt.Sprintf("/api/v1/restores/%s", backupID.String()),
"Bearer "+token,
http.StatusOK,
&restores,
)
for _, restore := range restores {
if restore.Status == restores_enums.RestoreStatusCompleted {
return restore
}
if restore.Status == restores_enums.RestoreStatusFailed {
t.Fatalf("Restore failed: %v", restore.FailMessage)
}
}
time.Sleep(pollInterval)
}
}
func createDatabaseViaAPI(
t *testing.T,
router *gin.Engine,
name string,
workspaceID uuid.UUID,
pgVersion tools.PostgresqlVersion,
host string,
port int,
username string,
password string,
database string,
token string,
) *databases.Database {
request := databases.Database{
Name: name,
WorkspaceID: &workspaceID,
Type: databases.DatabaseTypePostgres,
Postgresql: &pgtypes.PostgresqlDatabase{
Version: pgVersion,
Host: host,
Port: port,
Username: username,
Password: password,
Database: &database,
},
}
w := workspaces_testing.MakeAPIRequest(
router,
"POST",
"/api/v1/databases/create",
"Bearer "+token,
request,
)
if w.Code != http.StatusCreated {
t.Fatalf("Failed to create database. Status: %d, Body: %s", w.Code, w.Body.String())
}
var createdDatabase databases.Database
if err := json.Unmarshal(w.Body.Bytes(), &createdDatabase); err != nil {
t.Fatalf("Failed to unmarshal database response: %v", err)
}
return &createdDatabase
}
func enableBackupsViaAPI(
t *testing.T,
router *gin.Engine,
databaseID uuid.UUID,
storageID uuid.UUID,
encryption backups_config.BackupEncryption,
token string,
) {
var backupConfig backups_config.BackupConfig
test_utils.MakeGetRequestAndUnmarshal(
t,
router,
fmt.Sprintf("/api/v1/backup-configs/database/%s", databaseID.String()),
"Bearer "+token,
http.StatusOK,
&backupConfig,
)
storage := &storages.Storage{ID: storageID}
backupConfig.IsBackupsEnabled = true
backupConfig.Storage = storage
backupConfig.Encryption = encryption
test_utils.MakePostRequest(
t,
router,
"/api/v1/backup-configs/save",
"Bearer "+token,
backupConfig,
http.StatusOK,
)
}
func createBackupViaAPI(
t *testing.T,
router *gin.Engine,
databaseID uuid.UUID,
token string,
) {
request := backups.MakeBackupRequest{DatabaseID: databaseID}
test_utils.MakePostRequest(
t,
router,
"/api/v1/backups",
"Bearer "+token,
request,
http.StatusOK,
)
}
func createRestoreViaAPI(
t *testing.T,
router *gin.Engine,
backupID uuid.UUID,
pgVersion tools.PostgresqlVersion,
host string,
port int,
username string,
password string,
database string,
token string,
) {
request := restores.RestoreBackupRequest{
PostgresqlDatabase: &pgtypes.PostgresqlDatabase{
Version: pgVersion,
Host: host,
Port: port,
Username: username,
Password: password,
Database: &database,
},
}
test_utils.MakePostRequest(
t,
router,
fmt.Sprintf("/api/v1/restores/%s/restore", backupID.String()),
"Bearer "+token,
request,
http.StatusOK,
)
}
// verifyDataIntegrity compares data in the original and restored databases
func verifyDataIntegrity(t *testing.T, originalDB *sqlx.DB, restoredDB *sqlx.DB) {
var originalData []TestDataItem
var restoredData []TestDataItem
@@ -382,7 +516,6 @@ func verifyDataIntegrity(t *testing.T, originalDB *sqlx.DB, restoredDB *sqlx.DB)
assert.Equal(t, len(originalData), len(restoredData), "Should have same number of rows")
// Only compare data if both slices have elements (to avoid panic)
if len(originalData) > 0 && len(restoredData) > 0 {
for i := range originalData {
assert.Equal(t, originalData[i].ID, restoredData[i].ID, "ID should match")

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)
}
@@ -309,15 +310,6 @@ func (s *UserService) ChangeUserPasswordByEmail(email string, newPassword string
}
func (s *UserService) ChangeUserPassword(userID uuid.UUID, newPassword string) error {
user, err := s.userRepository.GetUserByID(userID)
if err != nil {
return fmt.Errorf("failed to get user: %w", err)
}
if !user.HasPassword() {
return errors.New("user has no password set")
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("failed to hash new password: %w", err)

View File

@@ -0,0 +1,11 @@
package encryption
import "postgresus-backend/internal/features/encryption/secrets"
var fieldEncryptor = &SecretKeyFieldEncryptor{
secrets.GetSecretKeyService(),
}
func GetFieldEncryptor() FieldEncryptor {
return fieldEncryptor
}

View File

@@ -0,0 +1,15 @@
package encryption
import "github.com/google/uuid"
type FieldEncryptor interface {
// Encrypt encrypts a plaintext string and returns an encrypted string.
// If the string is already encrypted, returns it as-is.
// Empty strings are returned unchanged.
Encrypt(itemID uuid.UUID, plaintext string) (string, error)
// Decrypt decrypts an encrypted string and returns a plaintext string.
// If the string is not encrypted, returns it as-is.
// Empty strings are returned unchanged.
Decrypt(itemID uuid.UUID, ciphertext string) (string, error)
}

View File

@@ -0,0 +1,120 @@
package encryption
import (
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"postgresus-backend/internal/features/encryption/secrets"
"strings"
"github.com/google/uuid"
)
const encryptedPrefix = "enc:"
type SecretKeyFieldEncryptor struct {
secretKeyService *secrets.SecretKeyService
}
func (e *SecretKeyFieldEncryptor) Encrypt(itemID uuid.UUID, plaintext string) (string, error) {
if plaintext == "" {
return "", nil
}
if e.isEncrypted(plaintext) {
return plaintext, nil
}
masterKey, err := e.secretKeyService.GetSecretKey()
if err != nil {
return "", fmt.Errorf("failed to get master key: %w", err)
}
block, err := aes.NewCipher([]byte(masterKey)[:32])
if err != nil {
return "", fmt.Errorf("failed to create cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("failed to create GCM: %w", err)
}
nonce := e.deriveNonce(itemID, masterKey, gcm.NonceSize())
ciphertext := gcm.Seal(nil, nonce, []byte(plaintext), nil)
nonceBase64 := base64.StdEncoding.EncodeToString(nonce)
ciphertextBase64 := base64.StdEncoding.EncodeToString(ciphertext)
return fmt.Sprintf("%s%s:%s", encryptedPrefix, nonceBase64, ciphertextBase64), nil
}
func (e *SecretKeyFieldEncryptor) Decrypt(itemID uuid.UUID, ciphertext string) (string, error) {
if ciphertext == "" {
return "", nil
}
if !e.isEncrypted(ciphertext) {
return ciphertext, nil
}
parts := strings.SplitN(ciphertext, ":", 3)
if len(parts) != 3 {
return "", errors.New("invalid encrypted format")
}
nonceBase64 := parts[1]
ciphertextBase64 := parts[2]
nonce, err := base64.StdEncoding.DecodeString(nonceBase64)
if err != nil {
return "", fmt.Errorf("failed to decode nonce: %w", err)
}
encryptedData, err := base64.StdEncoding.DecodeString(ciphertextBase64)
if err != nil {
return "", fmt.Errorf("failed to decode ciphertext: %w", err)
}
masterKey, err := e.secretKeyService.GetSecretKey()
if err != nil {
return "", fmt.Errorf("failed to get master key: %w", err)
}
block, err := aes.NewCipher([]byte(masterKey)[:32])
if err != nil {
return "", fmt.Errorf("failed to create cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("failed to create GCM: %w", err)
}
plaintext, err := gcm.Open(nil, nonce, encryptedData, nil)
if err != nil {
return "", fmt.Errorf("failed to decrypt: %w", err)
}
return string(plaintext), nil
}
func (e *SecretKeyFieldEncryptor) isEncrypted(value string) bool {
return strings.HasPrefix(value, encryptedPrefix)
}
func (e *SecretKeyFieldEncryptor) deriveNonce(
itemID uuid.UUID,
masterKey string,
nonceSize int,
) []byte {
h := hmac.New(sha256.New, []byte(masterKey))
h.Write(itemID[:])
hash := h.Sum(nil)
return hash[:nonceSize]
}

View File

@@ -0,0 +1,120 @@
package encryption
import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
)
func Test_Encrypt_Decrypt_RoundTrip(t *testing.T) {
encryptor := GetFieldEncryptor()
itemID := uuid.New()
plaintext := "my-secret-password"
encrypted, err := encryptor.Encrypt(itemID, plaintext)
assert.NoError(t, err)
assert.NotEmpty(t, encrypted)
assert.NotEqual(t, plaintext, encrypted)
assert.Contains(t, encrypted, "enc:")
decrypted, err := encryptor.Decrypt(itemID, encrypted)
assert.NoError(t, err)
assert.Equal(t, plaintext, decrypted)
}
func Test_Encrypt_EmptyString_ReturnsEmpty(t *testing.T) {
encryptor := GetFieldEncryptor()
itemID := uuid.New()
encrypted, err := encryptor.Encrypt(itemID, "")
assert.NoError(t, err)
assert.Empty(t, encrypted)
}
func Test_Decrypt_EmptyString_ReturnsEmpty(t *testing.T) {
encryptor := GetFieldEncryptor()
itemID := uuid.New()
decrypted, err := encryptor.Decrypt(itemID, "")
assert.NoError(t, err)
assert.Empty(t, decrypted)
}
func Test_Decrypt_PlaintextValue_ReturnsAsIs(t *testing.T) {
encryptor := GetFieldEncryptor()
itemID := uuid.New()
plaintext := "not-encrypted-password"
decrypted, err := encryptor.Decrypt(itemID, plaintext)
assert.NoError(t, err)
assert.Equal(t, plaintext, decrypted)
}
func Test_Encrypt_DetectsAlreadyEncryptedFormat(t *testing.T) {
encryptor := GetFieldEncryptor()
itemID := uuid.New()
alreadyEncrypted := "enc:nonce:ciphertext"
result, err := encryptor.Encrypt(itemID, alreadyEncrypted)
assert.NoError(t, err)
assert.Equal(t, alreadyEncrypted, result)
}
func Test_Encrypt_SamePlaintext_DifferentItemIDs_ProducesDifferentCiphertext(t *testing.T) {
encryptor := GetFieldEncryptor()
plaintext := "shared-secret"
itemID1 := uuid.New()
itemID2 := uuid.New()
encrypted1, err := encryptor.Encrypt(itemID1, plaintext)
assert.NoError(t, err)
encrypted2, err := encryptor.Encrypt(itemID2, plaintext)
assert.NoError(t, err)
assert.NotEqual(t, encrypted1, encrypted2)
decrypted1, err := encryptor.Decrypt(itemID1, encrypted1)
assert.NoError(t, err)
assert.Equal(t, plaintext, decrypted1)
decrypted2, err := encryptor.Decrypt(itemID2, encrypted2)
assert.NoError(t, err)
assert.Equal(t, plaintext, decrypted2)
}
func Test_Encrypt_AlreadyEncrypted_ReturnsAsIs(t *testing.T) {
encryptor := GetFieldEncryptor()
itemID := uuid.New()
plaintext := "my-password"
encrypted1, err := encryptor.Encrypt(itemID, plaintext)
assert.NoError(t, err)
encrypted2, err := encryptor.Encrypt(itemID, encrypted1)
assert.NoError(t, err)
assert.Equal(t, encrypted1, encrypted2)
}
func Test_Decrypt_MalformedData_ReturnsError(t *testing.T) {
encryptor := GetFieldEncryptor()
itemID := uuid.New()
_, err := encryptor.Decrypt(itemID, "enc:invalid")
assert.Error(t, err)
_, err = encryptor.Decrypt(itemID, "enc:invalid:invalid-base64")
assert.Error(t, err)
}
func Test_EncryptedFormat_ContainsPrefix(t *testing.T) {
encryptor := GetFieldEncryptor()
itemID := uuid.New()
plaintext := "test-secret"
encrypted, err := encryptor.Encrypt(itemID, plaintext)
assert.NoError(t, err)
assert.Contains(t, encrypted, "enc:")
}

View File

@@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
env_utils "postgresus-backend/internal/util/env"
)
@@ -151,6 +152,24 @@ func VerifyPostgresesInstallation(
logger.Info("All PostgreSQL version-specific client tools verification completed successfully!")
}
// EscapePgpassField escapes special characters in a field value for .pgpass file format.
// According to PostgreSQL documentation, the .pgpass file format requires:
// - Backslash (\) must be escaped as \\
// - Colon (:) must be escaped as \:
// Additionally, newlines and carriage returns are removed to prevent format corruption.
func EscapePgpassField(field string) string {
// Remove newlines and carriage returns that would break .pgpass format
field = strings.ReplaceAll(field, "\r", "")
field = strings.ReplaceAll(field, "\n", "")
// Escape backslashes first (order matters!)
// Then escape colons
field = strings.ReplaceAll(field, "\\", "\\\\")
field = strings.ReplaceAll(field, ":", "\\:")
return field
}
func getPostgresqlBasePath(
version PostgresqlVersion,
envMode env_utils.EnvMode,

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

@@ -0,0 +1,18 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE webhook_notifiers
ADD COLUMN body_template TEXT,
ADD COLUMN headers TEXT DEFAULT '[]';
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE webhook_notifiers
DROP COLUMN body_template,
DROP COLUMN headers;
-- +goose StatementEnd

View File

@@ -0,0 +1,23 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*.orig
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/

View File

@@ -0,0 +1,12 @@
apiVersion: v2
name: postgresus
description: A Helm chart for Postgresus - PostgreSQL backup and management system
type: application
version: 1.0.0
appVersion: "v1.45.3"
keywords:
- postgresql
- backup
- database
- restore
home: https://github.com/RostislavDugin/postgresus

View File

@@ -0,0 +1,84 @@
# Postgresus Helm Chart
## Installation
```bash
helm install postgresus ./deploy/postgresus -n postgresus --create-namespace
```
## Configuration
### Main Parameters
| Parameter | Description | Default Value |
| ------------------ | ------------------ | --------------------------- |
| `namespace.create` | Create namespace | `true` |
| `namespace.name` | Namespace name | `postgresus` |
| `image.repository` | Docker image | `rostislavdugin/postgresus` |
| `image.tag` | Image tag | `latest` |
| `image.pullPolicy` | Image pull policy | `Always` |
| `replicaCount` | Number of replicas | `1` |
### Resources
| Parameter | Description | Default Value |
| --------------------------- | -------------- | ------------- |
| `resources.requests.memory` | Memory request | `1Gi` |
| `resources.requests.cpu` | CPU request | `500m` |
| `resources.limits.memory` | Memory limit | `1Gi` |
| `resources.limits.cpu` | CPU limit | `500m` |
### Storage
| Parameter | Description | Default Value |
| ------------------------------ | ------------------------- | ---------------------- |
| `persistence.enabled` | Enable persistent storage | `true` |
| `persistence.storageClassName` | Storage class | `""` (cluster default) |
| `persistence.accessMode` | Access mode | `ReadWriteOnce` |
| `persistence.size` | Storage size | `10Gi` |
| `persistence.mountPath` | Mount path | `/postgresus-data` |
### Service
| Parameter | Description | Default Value |
| -------------------------- | ----------------------- | ------------- |
| `service.type` | Service type | `ClusterIP` |
| `service.port` | Service port | `4005` |
| `service.targetPort` | Target port | `4005` |
| `service.headless.enabled` | Enable headless service | `true` |
### Ingress
| Parameter | Description | Default Value |
| ----------------------- | ----------------- | ------------------------ |
| `ingress.enabled` | Enable Ingress | `true` |
| `ingress.className` | Ingress class | `nginx` |
| `ingress.hosts[0].host` | Hostname | `postgresus.example.com` |
| `ingress.tls` | TLS configuration | See values.yaml |
### Health Checks
| Parameter | Description | Default Value |
| ------------------------ | ---------------------- | ------------- |
| `livenessProbe.enabled` | Enable liveness probe | `true` |
| `readinessProbe.enabled` | Enable readiness probe | `true` |
## Custom Ingress Example
```yaml
# custom-values.yaml
ingress:
hosts:
- host: backup.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: backup-example-com-tls
hosts:
- backup.example.com
```
```bash
helm install postgresus ./deploy/postgresus -n postgresus --create-namespace -f custom-values.yaml
```

View File

@@ -0,0 +1,72 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "postgresus.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "postgresus.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "postgresus.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "postgresus.labels" -}}
helm.sh/chart: {{ include "postgresus.chart" . }}
{{ include "postgresus.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "postgresus.selectorLabels" -}}
app.kubernetes.io/name: {{ include "postgresus.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app: postgresus
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "postgresus.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "postgresus.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}
{{/*
Namespace
*/}}
{{- define "postgresus.namespace" -}}
{{- if .Values.namespace.create }}
{{- .Values.namespace.name }}
{{- else }}
{{- .Release.Namespace }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,42 @@
{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "postgresus.fullname" . }}-ingress
namespace: {{ include "postgresus.namespace" . }}
labels:
{{- include "postgresus.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "postgresus.fullname" $ }}-service
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,8 @@
{{- if .Values.namespace.create }}
apiVersion: v1
kind: Namespace
metadata:
name: {{ .Values.namespace.name }}
labels:
{{- include "postgresus.labels" . | nindent 4 }}
{{- end }}

View File

@@ -0,0 +1,36 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "postgresus.fullname" . }}-service
namespace: {{ include "postgresus.namespace" . }}
labels:
{{- include "postgresus.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: {{ .Values.service.targetPort }}
protocol: TCP
name: http
selector:
{{- include "postgresus.selectorLabels" . | nindent 4 }}
---
{{- if .Values.service.headless.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "postgresus.fullname" . }}-headless
namespace: {{ include "postgresus.namespace" . }}
labels:
{{- include "postgresus.labels" . | nindent 4 }}
spec:
type: ClusterIP
clusterIP: None
ports:
- port: {{ .Values.service.port }}
targetPort: {{ .Values.service.targetPort }}
protocol: TCP
name: http
selector:
{{- include "postgresus.selectorLabels" . | nindent 4 }}
{{- end }}

View File

@@ -0,0 +1,82 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ include "postgresus.fullname" . }}
namespace: {{ include "postgresus.namespace" . }}
labels:
{{- include "postgresus.labels" . | nindent 4 }}
spec:
serviceName: {{ include "postgresus.fullname" . }}-headless
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "postgresus.selectorLabels" . | nindent 6 }}
template:
metadata:
annotations:
{{- with .Values.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "postgresus.selectorLabels" . | nindent 8 }}
{{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.targetPort }}
protocol: TCP
volumeMounts:
- name: postgresus-storage
mountPath: {{ .Values.persistence.mountPath }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- if .Values.livenessProbe.enabled }}
livenessProbe:
{{- toYaml .Values.livenessProbe.httpGet | nindent 12 }}
initialDelaySeconds: {{ .Values.livenessProbe.initialDelaySeconds }}
periodSeconds: {{ .Values.livenessProbe.periodSeconds }}
timeoutSeconds: {{ .Values.livenessProbe.timeoutSeconds }}
failureThreshold: {{ .Values.livenessProbe.failureThreshold }}
{{- end }}
{{- if .Values.readinessProbe.enabled }}
readinessProbe:
{{- toYaml .Values.readinessProbe.httpGet | nindent 12 }}
initialDelaySeconds: {{ .Values.readinessProbe.initialDelaySeconds }}
periodSeconds: {{ .Values.readinessProbe.periodSeconds }}
timeoutSeconds: {{ .Values.readinessProbe.timeoutSeconds }}
failureThreshold: {{ .Values.readinessProbe.failureThreshold }}
{{- end }}
{{- if .Values.persistence.enabled }}
volumeClaimTemplates:
- metadata:
name: postgresus-storage
spec:
accessModes:
- {{ .Values.persistence.accessMode }}
{{- if .Values.persistence.storageClassName }}
storageClassName: {{ .Values.persistence.storageClassName }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.size }}
{{- end }}
updateStrategy:
{{- toYaml .Values.updateStrategy | nindent 4 }}

View File

@@ -0,0 +1,111 @@
# Default values for postgresus
# Namespace configuration
namespace:
create: true
name: postgresus
# Image configuration
image:
repository: rostislavdugin/postgresus
tag: latest
pullPolicy: Always
# StatefulSet configuration
replicaCount: 1
# Service configuration
service:
type: ClusterIP
port: 4005
targetPort: 4005
# Headless service for StatefulSet
headless:
enabled: true
# Resource limits and requests
resources:
requests:
memory: "1Gi"
cpu: "500m"
limits:
memory: "1Gi"
cpu: "500m"
# Persistent storage configuration
persistence:
enabled: true
# Storage class name. Leave empty to use cluster default.
# Examples: "longhorn", "standard", "gp2", etc.
storageClassName: ""
accessMode: ReadWriteOnce
size: 10Gi
# Mount path in container
mountPath: /postgresus-data
# Ingress configuration
ingress:
enabled: true
className: nginx
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/use-http2: "true"
# Gzip settings
nginx.ingress.kubernetes.io/enable-gzip: "true"
nginx.ingress.kubernetes.io/gzip-types: "text/plain text/css application/json application/javascript text/javascript text/xml application/xml application/xml+rss image/svg+xml"
nginx.ingress.kubernetes.io/gzip-min-length: "1000"
nginx.ingress.kubernetes.io/gzip-level: "9"
nginx.ingress.kubernetes.io/gzip-buffers: "16 8k"
nginx.ingress.kubernetes.io/gzip-http-version: "1.1"
nginx.ingress.kubernetes.io/gzip-vary: "on"
# Cert-manager settings
cert-manager.io/cluster-issuer: "clusteriissuer-letsencrypt"
cert-manager.io/duration: "2160h"
cert-manager.io/renew-before: "360h"
hosts:
- host: postgresus.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: postgresus.example.com-tls
hosts:
- postgresus.example.com
# Health checks configuration
# Note: The application only has /api/v1/system/health endpoint
livenessProbe:
enabled: true
httpGet:
path: /api/v1/system/health
port: 4005
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
enabled: true
httpGet:
path: /api/v1/system/health
port: 4005
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
# StatefulSet update strategy
updateStrategy:
type: RollingUpdate
rollingUpdate:
partition: 0
# Pod labels and annotations
podLabels: {}
podAnnotations: {}
# Node selector, tolerations and affinity
nodeSelector: {}
tolerations: []
affinity: {}

View File

@@ -1,4 +1,4 @@
import { App as AntdApp, ConfigProvider } from 'antd';
import { App as AntdApp, ConfigProvider, theme } from 'antd';
import { useEffect, useState } from 'react';
import { BrowserRouter, Route } from 'react-router';
import { Routes } from 'react-router';
@@ -7,10 +7,12 @@ import { userApi } from './entity/users';
import { AuthPageComponent } from './pages/AuthPageComponent';
import { OAuthCallbackPage } from './pages/OAuthCallbackPage';
import { OauthStorageComponent } from './pages/OauthStorageComponent';
import { ThemeProvider, useTheme } from './shared/theme';
import { MainScreenComponent } from './widgets/main/MainScreenComponent';
function App() {
function AppContent() {
const [isAuthorized, setIsAuthorized] = useState(false);
const { resolvedTheme } = useTheme();
useEffect(() => {
const isAuthorized = userApi.isAuthorized();
@@ -24,6 +26,7 @@ function App() {
return (
<ConfigProvider
theme={{
algorithm: resolvedTheme === 'dark' ? theme.darkAlgorithm : theme.defaultAlgorithm,
token: {
colorPrimary: '#155dfc', // Tailwind blue-600
},
@@ -45,4 +48,12 @@ function App() {
);
}
function App() {
return (
<ThemeProvider>
<AppContent />
</ThemeProvider>
);
}
export default App;

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 @@ export type { TelegramNotifier } from './models/telegram/TelegramNotifier';
export { validateTelegramNotifier } from './models/telegram/validateTelegramNotifier';
export type { WebhookNotifier } from './models/webhook/WebhookNotifier';
export type { WebhookHeader } from './models/webhook/WebhookHeader';
export { validateWebhookNotifier } from './models/webhook/validateWebhookNotifier';
export { WebhookMethod } from './models/webhook/WebhookMethod';

View File

@@ -0,0 +1,4 @@
export interface WebhookHeader {
key: string;
value: string;
}

View File

@@ -1,6 +1,9 @@
import type { WebhookHeader } from './WebhookHeader';
import type { WebhookMethod } from './WebhookMethod';
export interface WebhookNotifier {
webhookUrl: string;
webhookMethod: WebhookMethod;
bodyTemplate?: string;
headers?: WebhookHeader[];
}

View File

@@ -281,6 +281,163 @@ export const BackupsComponent = ({ database, isCanManageDBs, scrollContainerRef
return () => container.removeEventListener('scroll', handleScroll);
}, [hasMore, isLoadingMore, currentLimit, scrollContainerRef]);
const renderStatus = (status: BackupStatus, record: Backup) => {
if (status === BackupStatus.FAILED) {
return (
<Tooltip title="Click to see error details">
<div
className="flex cursor-pointer items-center text-red-600 underline"
onClick={() => setShowingBackupError(record)}
>
<ExclamationCircleOutlined className="mr-2" style={{ fontSize: 16 }} />
<div>Failed</div>
</div>
</Tooltip>
);
}
if (status === BackupStatus.COMPLETED) {
return (
<div className="flex items-center text-green-600">
<CheckCircleOutlined className="mr-2" style={{ fontSize: 16 }} />
<div>Successful</div>
{record.encryption === BackupEncryption.ENCRYPTED && (
<Tooltip title="Encrypted">
<LockOutlined className="ml-1" style={{ fontSize: 14 }} />
</Tooltip>
)}
</div>
);
}
if (status === BackupStatus.DELETED) {
return (
<div className="flex items-center text-gray-600">
<DeleteOutlined className="mr-2" style={{ fontSize: 16 }} />
<div>Deleted</div>
</div>
);
}
if (status === BackupStatus.IN_PROGRESS) {
return (
<div className="flex items-center font-bold text-blue-600">
<SyncOutlined spin />
<span className="ml-2">In progress</span>
</div>
);
}
if (status === BackupStatus.CANCELED) {
return (
<div className="flex items-center text-gray-600">
<CloseCircleOutlined className="mr-2" style={{ fontSize: 16 }} />
<div>Canceled</div>
</div>
);
}
return <span className="font-bold">{status}</span>;
};
const renderActions = (record: Backup) => {
return (
<div className="flex gap-2 text-lg">
{record.status === BackupStatus.IN_PROGRESS && isCanManageDBs && (
<div className="flex gap-2">
{cancellingBackupId === record.id ? (
<SyncOutlined spin />
) : (
<Tooltip title="Cancel backup">
<CloseCircleOutlined
className="cursor-pointer"
onClick={() => {
if (cancellingBackupId) return;
cancelBackup(record.id);
}}
style={{ color: '#ff0000', opacity: cancellingBackupId ? 0.2 : 1 }}
/>
</Tooltip>
)}
</div>
)}
{record.status === BackupStatus.COMPLETED && (
<div className="flex gap-2">
{deletingBackupId === record.id ? (
<SyncOutlined spin />
) : (
<>
{isCanManageDBs && (
<Tooltip title="Delete backup">
<DeleteOutlined
className="cursor-pointer"
onClick={() => {
if (deletingBackupId) return;
setDeleteConfimationId(record.id);
}}
style={{ color: '#ff0000', opacity: deletingBackupId ? 0.2 : 1 }}
/>
</Tooltip>
)}
<Tooltip title="Restore from backup">
<CloudUploadOutlined
className="cursor-pointer"
onClick={() => {
setShowingRestoresBackupId(record.id);
}}
style={{
color: '#155dfc',
}}
/>
</Tooltip>
<Tooltip title="Download backup file. It can be restored manually via pg_restore (from custom format)">
{downloadingBackupId === record.id ? (
<SyncOutlined spin style={{ color: '#155dfc' }} />
) : (
<DownloadOutlined
className="cursor-pointer"
onClick={() => {
if (downloadingBackupId) return;
setDownloadingBackupId(record.id);
}}
style={{
opacity: downloadingBackupId ? 0.2 : 1,
color: '#155dfc',
}}
/>
)}
</Tooltip>
</>
)}
</div>
)}
</div>
);
};
const formatSize = (sizeMb: number) => {
if (sizeMb >= 1024) {
const sizeGb = sizeMb / 1024;
return `${Number(sizeGb.toFixed(2)).toLocaleString()} GB`;
}
return `${Number(sizeMb?.toFixed(2)).toLocaleString()} MB`;
};
const formatDuration = (durationMs: number) => {
const hours = Math.floor(durationMs / 3600000);
const minutes = Math.floor((durationMs % 3600000) / 60000);
const seconds = Math.floor((durationMs % 60000) / 1000);
if (hours > 0) {
return `${hours}h ${minutes}m ${seconds}s`;
}
return `${minutes}m ${seconds}s`;
};
const columns: ColumnsType<Backup> = [
{
title: 'Created at',
@@ -289,7 +446,9 @@ export const BackupsComponent = ({ database, isCanManageDBs, scrollContainerRef
render: (createdAt: string) => (
<div>
{dayjs.utc(createdAt).local().format(getUserTimeFormat().format)} <br />
<span className="text-gray-500">({dayjs.utc(createdAt).local().fromNow()})</span>
<span className="text-gray-500 dark:text-gray-400">
({dayjs.utc(createdAt).local().fromNow()})
</span>
</div>
),
sorter: (a, b) => dayjs(a.createdAt).unix() - dayjs(b.createdAt).unix(),
@@ -299,66 +458,7 @@ export const BackupsComponent = ({ database, isCanManageDBs, scrollContainerRef
title: 'Status',
dataIndex: 'status',
key: 'status',
render: (status: BackupStatus, record: Backup) => {
if (status === BackupStatus.FAILED) {
return (
<Tooltip title="Click to see error details">
<div
className="flex cursor-pointer items-center text-red-600 underline"
onClick={() => setShowingBackupError(record)}
>
<ExclamationCircleOutlined className="mr-2" style={{ fontSize: 16 }} />
<div>Failed</div>
</div>
</Tooltip>
);
}
if (status === BackupStatus.COMPLETED) {
return (
<div className="flex items-center text-green-600">
<CheckCircleOutlined className="mr-2" style={{ fontSize: 16 }} />
<div>Successful</div>
{record.encryption === BackupEncryption.ENCRYPTED && (
<Tooltip title="Encrypted">
<LockOutlined className="ml-1" style={{ fontSize: 14 }} />
</Tooltip>
)}
</div>
);
}
if (status === BackupStatus.DELETED) {
return (
<div className="flex items-center text-gray-600">
<DeleteOutlined className="mr-2" style={{ fontSize: 16 }} />
<div>Deleted</div>
</div>
);
}
if (status === BackupStatus.IN_PROGRESS) {
return (
<div className="flex items-center font-bold text-blue-600">
<SyncOutlined spin />
<span className="ml-2">In progress</span>
</div>
);
}
if (status === BackupStatus.CANCELED) {
return (
<div className="flex items-center text-gray-600">
<CloseCircleOutlined className="mr-2" style={{ fontSize: 16 }} />
<div>Canceled</div>
</div>
);
}
return <span className="font-bold">{status}</span>;
},
render: (status: BackupStatus, record: Backup) => renderStatus(status, record),
filters: [
{
value: BackupStatus.IN_PROGRESS,
@@ -398,112 +498,20 @@ export const BackupsComponent = ({ database, isCanManageDBs, scrollContainerRef
dataIndex: 'backupSizeMb',
key: 'backupSizeMb',
width: 150,
render: (sizeMb: number) => {
if (sizeMb >= 1024) {
const sizeGb = sizeMb / 1024;
return `${Number(sizeGb.toFixed(2)).toLocaleString()} GB`;
}
return `${Number(sizeMb?.toFixed(2)).toLocaleString()} MB`;
},
render: (sizeMb: number) => formatSize(sizeMb),
},
{
title: 'Duration',
dataIndex: 'backupDurationMs',
key: 'backupDurationMs',
width: 150,
render: (durationMs: number) => {
const hours = Math.floor(durationMs / 3600000);
const minutes = Math.floor((durationMs % 3600000) / 60000);
const seconds = Math.floor((durationMs % 60000) / 1000);
if (hours > 0) {
return `${hours}h ${minutes}m ${seconds}s`;
}
return `${minutes}m ${seconds}s`;
},
render: (durationMs: number) => formatDuration(durationMs),
},
{
title: 'Actions',
dataIndex: '',
key: '',
render: (_, record: Backup) => {
return (
<div className="flex gap-2 text-lg">
{record.status === BackupStatus.IN_PROGRESS && isCanManageDBs && (
<div className="flex gap-2">
{cancellingBackupId === record.id ? (
<SyncOutlined spin />
) : (
<Tooltip title="Cancel backup">
<CloseCircleOutlined
className="cursor-pointer"
onClick={() => {
if (cancellingBackupId) return;
cancelBackup(record.id);
}}
style={{ color: '#ff0000', opacity: cancellingBackupId ? 0.2 : 1 }}
/>
</Tooltip>
)}
</div>
)}
{record.status === BackupStatus.COMPLETED && (
<div className="flex gap-2">
{deletingBackupId === record.id ? (
<SyncOutlined spin />
) : (
<>
{isCanManageDBs && (
<Tooltip title="Delete backup">
<DeleteOutlined
className="cursor-pointer"
onClick={() => {
if (deletingBackupId) return;
setDeleteConfimationId(record.id);
}}
style={{ color: '#ff0000', opacity: deletingBackupId ? 0.2 : 1 }}
/>
</Tooltip>
)}
<Tooltip title="Restore from backup">
<CloudUploadOutlined
className="cursor-pointer"
onClick={() => {
setShowingRestoresBackupId(record.id);
}}
style={{
color: '#155dfc',
}}
/>
</Tooltip>
<Tooltip title="Download backup file. It can be restored manually via pg_restore (from custom format)">
{downloadingBackupId === record.id ? (
<SyncOutlined spin style={{ color: '#155dfc' }} />
) : (
<DownloadOutlined
className="cursor-pointer"
onClick={() => {
if (downloadingBackupId) return;
setDownloadingBackupId(record.id);
}}
style={{
opacity: downloadingBackupId ? 0.2 : 1,
color: '#155dfc',
}}
/>
)}
</Tooltip>
</>
)}
</div>
)}
</div>
);
},
render: (_, record: Backup) => renderActions(record),
},
];
@@ -516,11 +524,11 @@ export const BackupsComponent = ({ database, isCanManageDBs, scrollContainerRef
}
return (
<div className="mt-5 w-full rounded-md bg-white p-5 shadow">
<h2 className="text-xl font-bold">Backups</h2>
<div className="mt-5 w-full rounded-md bg-white p-3 shadow md:p-5 dark:bg-gray-800">
<h2 className="text-lg font-bold md:text-xl dark:text-white">Backups</h2>
{!isBackupConfigLoading && !backupConfig?.isBackupsEnabled && (
<div className="text-red-600">
<div className="text-sm text-red-600 md:text-base">
Scheduled backups are disabled (you can enable it back in the backup configuration)
</div>
)}
@@ -535,30 +543,98 @@ export const BackupsComponent = ({ database, isCanManageDBs, scrollContainerRef
disabled={isMakeBackupRequestLoading}
loading={isMakeBackupRequestLoading}
>
Make backup right now
<span className="md:hidden">Backup now</span>
<span className="hidden md:inline">Make backup right now</span>
</Button>
</div>
<div className="mt-5 max-w-[850px]">
<Table
bordered
columns={columns}
dataSource={backups}
rowKey="id"
loading={isBackupsLoading}
size="small"
pagination={false}
/>
{isLoadingMore && (
<div className="mt-2 flex justify-center">
<Spin />
</div>
)}
{!hasMore && backups.length > 0 && (
<div className="mt-2 text-center text-gray-500">
All backups loaded ({totalBackups} total)
</div>
)}
<div className="mt-5 w-full md:max-w-[850px]">
{/* Mobile card view */}
<div className="md:hidden">
{isBackupsLoading ? (
<div className="flex justify-center py-8">
<Spin />
</div>
) : (
<div>
{backups.map((backup) => (
<div
key={backup.id}
className="mb-2 rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-800"
>
<div className="space-y-3">
<div className="flex items-start justify-between">
<div>
<div className="text-xs text-gray-500 dark:text-gray-400">Created at</div>
<div className="text-sm font-medium">
{dayjs.utc(backup.createdAt).local().format(getUserTimeFormat().format)}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
({dayjs.utc(backup.createdAt).local().fromNow()})
</div>
</div>
<div>{renderStatus(backup.status, backup)}</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-xs text-gray-500 dark:text-gray-400">Size</div>
<div className="text-sm font-medium">{formatSize(backup.backupSizeMb)}</div>
</div>
<div>
<div className="text-xs text-gray-500 dark:text-gray-400">Duration</div>
<div className="text-sm font-medium">
{formatDuration(backup.backupDurationMs)}
</div>
</div>
</div>
<div className="flex items-center justify-end border-t border-gray-200 pt-3">
{renderActions(backup)}
</div>
</div>
</div>
))}
</div>
)}
{isLoadingMore && (
<div className="mt-3 flex justify-center">
<Spin />
</div>
)}
{!hasMore && backups.length > 0 && (
<div className="mt-3 text-center text-sm text-gray-500 dark:text-gray-400">
All backups loaded ({totalBackups} total)
</div>
)}
{!isBackupsLoading && backups.length === 0 && (
<div className="py-8 text-center text-gray-500 dark:text-gray-400">No backups yet</div>
)}
</div>
{/* Desktop table view */}
<div className="hidden md:block">
<Table
bordered
columns={columns}
dataSource={backups}
rowKey="id"
loading={isBackupsLoading}
size="small"
pagination={false}
/>
{isLoadingMore && (
<div className="mt-2 flex justify-center">
<Spin />
</div>
)}
{!hasMore && backups.length > 0 && (
<div className="mt-2 text-center text-gray-500 dark:text-gray-400">
All backups loaded ({totalBackups} total)
</div>
)}
</div>
</div>
{deleteConfimationId && (

View File

@@ -204,8 +204,8 @@ export const EditBackupConfigComponent = ({
return (
<div>
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">Backups enabled</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">Backups enabled</div>
<Switch
checked={backupConfig.isBackupsEnabled}
onChange={(checked) => {
@@ -217,13 +217,13 @@ export const EditBackupConfigComponent = ({
{backupConfig.isBackupsEnabled && (
<>
<div className="mt-4 mb-1 flex w-full items-center">
<div className="min-w-[150px]">Backup interval</div>
<div className="mt-4 mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">Backup interval</div>
<Select
value={backupInterval?.interval}
onChange={(v) => saveInterval({ interval: v })}
size="small"
className="max-w-[200px] grow"
className="w-full max-w-[200px] grow"
options={[
{ label: 'Hourly', value: IntervalType.HOURLY },
{ label: 'Daily', value: IntervalType.DAILY },
@@ -234,8 +234,8 @@ export const EditBackupConfigComponent = ({
</div>
{backupInterval?.interval === IntervalType.WEEKLY && (
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">Backup weekday</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">Backup weekday</div>
<Select
value={displayedWeekday}
onChange={(localWeekday) => {
@@ -244,15 +244,15 @@ export const EditBackupConfigComponent = ({
saveInterval({ weekday: getUtcWeekday(localWeekday, ref) });
}}
size="small"
className="max-w-[200px] grow"
className="w-full max-w-[200px] grow"
options={weekdayOptions}
/>
</div>
)}
{backupInterval?.interval === IntervalType.MONTHLY && (
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">Backup day of month</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">Backup day of month</div>
<InputNumber
min={1}
max={31}
@@ -263,21 +263,21 @@ export const EditBackupConfigComponent = ({
saveInterval({ dayOfMonth: getUtcDayOfMonth(localDom, ref) });
}}
size="small"
className="max-w-[200px] grow"
className="w-full max-w-[200px] grow"
/>
</div>
)}
{backupInterval?.interval !== IntervalType.HOURLY && (
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">Backup time of day</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">Backup time of day</div>
<TimePicker
value={localTime}
format={timeFormat.format}
use12Hours={timeFormat.use12Hours}
allowClear={false}
size="small"
className="max-w-[200px] grow"
className="w-full max-w-[200px] grow"
onChange={(t) => {
if (!t) return;
const patch: Partial<Interval> = { timeOfDay: t.utc().format('HH:mm') };
@@ -295,156 +295,168 @@ export const EditBackupConfigComponent = ({
</div>
)}
<div className="mt-4 mb-1 flex w-full items-center">
<div className="min-w-[150px]">Retry backup if failed</div>
<Switch
size="small"
checked={backupConfig.isRetryIfFailed}
onChange={(checked) => updateBackupConfig({ isRetryIfFailed: checked })}
/>
<Tooltip
className="cursor-pointer"
title="Automatically retry failed backups. Backups can fail due to network failures, storage issues or temporary database unavailability."
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
{backupConfig.isRetryIfFailed && (
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">Max failed tries count</div>
<InputNumber
min={1}
max={10}
value={backupConfig.maxFailedTriesCount}
onChange={(value) => updateBackupConfig({ maxFailedTriesCount: value || 1 })}
<div className="mt-4 mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">Retry backup if failed</div>
<div className="flex items-center">
<Switch
size="small"
className="max-w-[200px] grow"
checked={backupConfig.isRetryIfFailed}
onChange={(checked) => updateBackupConfig({ isRetryIfFailed: checked })}
/>
<Tooltip
className="cursor-pointer"
title="Maximum number of retry attempts for failed backups. You will receive a notification when all tries have failed."
title="Automatically retry failed backups. Backups can fail due to network failures, storage issues or temporary database unavailability."
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
)}
<div className="mt-5 mb-1 flex w-full items-center">
<div className="min-w-[150px]">CPU count</div>
<InputNumber
min={1}
max={16}
value={backupConfig.cpuCount}
onChange={(value) => updateBackupConfig({ cpuCount: value || 1 })}
size="small"
className="max-w-[200px] grow"
/>
<Tooltip
className="cursor-pointer"
title="Number of CPU cores to use for restore processing. Higher values may speed up restores, but use more resources."
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">Store period</div>
<Select
value={backupConfig.storePeriod}
onChange={(v) => updateBackupConfig({ storePeriod: v })}
size="small"
className="max-w-[200px] grow"
options={[
{ label: '1 day', value: Period.DAY },
{ label: '1 week', value: Period.WEEK },
{ label: '1 month', value: Period.MONTH },
{ label: '3 months', value: Period.THREE_MONTH },
{ label: '6 months', value: Period.SIX_MONTH },
{ label: '1 year', value: Period.YEAR },
{ label: '2 years', value: Period.TWO_YEARS },
{ label: '3 years', value: Period.THREE_YEARS },
{ label: '4 years', value: Period.FOUR_YEARS },
{ label: '5 years', value: Period.FIVE_YEARS },
{ label: 'Forever', value: Period.FOREVER },
]}
/>
{backupConfig.isRetryIfFailed && (
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">Max failed tries count</div>
<div className="flex items-center">
<InputNumber
min={1}
max={10}
value={backupConfig.maxFailedTriesCount}
onChange={(value) => updateBackupConfig({ maxFailedTriesCount: value || 1 })}
size="small"
className="w-full max-w-[200px] grow"
/>
<Tooltip
className="cursor-pointer"
title="How long to keep the backups? Make sure you have enough storage space."
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
<Tooltip
className="cursor-pointer"
title="Maximum number of retry attempts for failed backups. You will receive a notification when all tries have failed."
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
</div>
)}
<div className="mt-5 mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">CPU count</div>
<div className="flex items-center">
<InputNumber
min={1}
max={16}
value={backupConfig.cpuCount}
onChange={(value) => updateBackupConfig({ cpuCount: value || 1 })}
size="small"
className="w-full max-w-[200px] grow"
/>
<Tooltip
className="cursor-pointer"
title="Number of CPU cores to use for restore processing. Higher values may speed up restores, but use more resources."
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">Store period</div>
<div className="flex items-center">
<Select
value={backupConfig.storePeriod}
onChange={(v) => updateBackupConfig({ storePeriod: v })}
size="small"
className="w-full max-w-[200px] grow"
options={[
{ label: '1 day', value: Period.DAY },
{ label: '1 week', value: Period.WEEK },
{ label: '1 month', value: Period.MONTH },
{ label: '3 months', value: Period.THREE_MONTH },
{ label: '6 months', value: Period.SIX_MONTH },
{ label: '1 year', value: Period.YEAR },
{ label: '2 years', value: Period.TWO_YEARS },
{ label: '3 years', value: Period.THREE_YEARS },
{ label: '4 years', value: Period.FOUR_YEARS },
{ label: '5 years', value: Period.FIVE_YEARS },
{ label: 'Forever', value: Period.FOREVER },
]}
/>
<Tooltip
className="cursor-pointer"
title="How long to keep the backups? Make sure you have enough storage space."
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
</div>
<div className="mb-3" />
</>
)}
<div className="mt-2 mb-1 flex w-full items-center">
<div className="min-w-[150px]">Storage</div>
<Select
value={backupConfig.storage?.id}
onChange={(storageId) => {
if (storageId.includes('create-new-storage')) {
setShowCreateStorage(true);
return;
}
<div className="mt-2 mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">Storage</div>
<div className="flex w-full items-center">
<Select
value={backupConfig.storage?.id}
onChange={(storageId) => {
if (storageId.includes('create-new-storage')) {
setShowCreateStorage(true);
return;
}
const selectedStorage = storages.find((s) => s.id === storageId);
updateBackupConfig({ storage: selectedStorage });
const selectedStorage = storages.find((s) => s.id === storageId);
updateBackupConfig({ storage: selectedStorage });
if (backupConfig.storage?.id) {
setIsShowWarn(true);
}
}}
size="small"
className="mr-2 max-w-[200px] grow"
options={[
...storages.map((s) => ({ label: s.name, value: s.id })),
{ label: 'Create new storage', value: 'create-new-storage' },
]}
placeholder="Select storage"
/>
{backupConfig.storage?.type && (
<img
src={getStorageLogoFromType(backupConfig.storage.type)}
alt="storageIcon"
className="ml-1 h-4 w-4"
if (backupConfig.storage?.id) {
setIsShowWarn(true);
}
}}
size="small"
className="mr-2 max-w-[200px] grow"
options={[
...storages.map((s) => ({ label: s.name, value: s.id })),
{ label: 'Create new storage', value: 'create-new-storage' },
]}
placeholder="Select storage"
/>
)}
{backupConfig.storage?.type && (
<img
src={getStorageLogoFromType(backupConfig.storage.type)}
alt="storageIcon"
className="ml-1 h-4 w-4"
/>
)}
</div>
</div>
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">Encryption</div>
<Select
value={backupConfig.encryption}
onChange={(v) => updateBackupConfig({ encryption: v })}
size="small"
className="max-w-[200px] grow"
options={[
{ label: 'None', value: BackupEncryption.NONE },
{ label: 'Encrypt backup files', value: BackupEncryption.ENCRYPTED },
]}
/>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">Encryption</div>
<div className="flex items-center">
<Select
value={backupConfig.encryption}
onChange={(v) => updateBackupConfig({ encryption: v })}
size="small"
className="w-full max-w-[200px] grow"
options={[
{ label: 'None', value: BackupEncryption.NONE },
{ label: 'Encrypt backup files', value: BackupEncryption.ENCRYPTED },
]}
/>
<Tooltip
className="cursor-pointer"
title="If backup is encrypted, backup files in your storage (S3, local, etc.) cannot be used directly. You can restore backups through Postgresus or download them unencrypted via the 'Download' button."
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
<Tooltip
className="cursor-pointer"
title="If backup is encrypted, backup files in your storage (S3, local, etc.) cannot be used directly. You can restore backups through Postgresus or download them unencrypted via the 'Download' button."
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
</div>
{backupConfig.isBackupsEnabled && (
<>
<div className="mt-4 mb-1 flex w-full items-start">
<div className="mt-1 min-w-[150px]">Notifications</div>
<div className="mt-4 mb-1 flex w-full flex-col items-start sm:flex-row sm:items-start">
<div className="mt-0 mb-1 min-w-[150px] sm:mt-1 sm:mb-0">Notifications</div>
<div className="flex flex-col space-y-2">
<Checkbox
checked={backupConfig.sendNotificationsOn.includes(
@@ -517,7 +529,7 @@ export const EditBackupConfigComponent = ({
open={isShowCreateStorage}
onCancel={() => setShowCreateStorage(false)}
>
<div className="my-3 max-w-[275px] text-gray-500">
<div className="my-3 max-w-[275px] text-gray-500 dark:text-gray-400">
Storage - is a place where backups will be stored (local disk, S3, Google Drive, etc.)
</div>

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

@@ -29,7 +29,7 @@ export const DatabaseCardComponent = ({
return (
<div
className={`mb-3 cursor-pointer rounded p-3 shadow ${selectedDatabaseId === database.id ? 'bg-blue-100' : 'bg-white'}`}
className={`mb-3 cursor-pointer rounded p-3 shadow ${selectedDatabaseId === database.id ? 'bg-blue-100 dark:bg-blue-800' : 'bg-white dark:bg-gray-800'}`}
onClick={() => setSelectedDatabaseId(database.id)}
>
<div className="flex">
@@ -49,7 +49,7 @@ export const DatabaseCardComponent = ({
</div>
{storage && (
<div className="text-sm text-gray-500">
<div className="text-sm text-gray-500 dark:text-gray-400">
<span>Storage: </span>
<span className="inline-flex items-center">
{storage.name}{' '}
@@ -65,11 +65,13 @@ export const DatabaseCardComponent = ({
)}
{database.lastBackupTime && (
<div className="text-gray-500">Last backup {dayjs(database.lastBackupTime).fromNow()}</div>
<div className="text-gray-500 dark:text-gray-400">
Last backup {dayjs(database.lastBackupTime).fromNow()}
</div>
)}
{database.lastBackupErrorMessage && (
<div className="mt-1 flex items-center text-sm text-red-600 underline">
<div className="mt-1 flex items-center text-sm text-red-600 underline dark:text-red-400">
<InfoCircleOutlined className="mr-1" style={{ color: 'red' }} />
Has backup error
</div>

View File

@@ -51,14 +51,14 @@ export const DatabaseComponent = ({
>
<div className="flex">
<div
className={`mr-2 cursor-pointer rounded-tl-md rounded-tr-md px-6 py-2 ${currentTab === 'config' ? 'bg-white' : 'bg-gray-200'}`}
className={`mr-2 cursor-pointer rounded-tl-md rounded-tr-md px-6 py-2 ${currentTab === 'config' ? 'bg-white dark:bg-gray-800' : 'bg-gray-200 dark:bg-gray-700'}`}
onClick={() => setCurrentTab('config')}
>
Config
</div>
<div
className={`mr-2 cursor-pointer rounded-tl-md rounded-tr-md px-6 py-2 ${currentTab === 'backups' ? 'bg-white' : 'bg-gray-200'}`}
className={`mr-2 cursor-pointer rounded-tl-md rounded-tr-md px-6 py-2 ${currentTab === 'backups' ? 'bg-white dark:bg-gray-800' : 'bg-gray-200 dark:bg-gray-700'}`}
onClick={() => setCurrentTab('backups')}
>
Backups

View File

@@ -147,9 +147,9 @@ export const DatabaseConfigComponent = ({
};
return (
<div className="w-full rounded-tr-md rounded-br-md rounded-bl-md bg-white p-5 shadow">
<div className="w-full rounded-tr-md rounded-br-md rounded-bl-md bg-white p-3 shadow sm:p-5 dark:bg-gray-800">
{!isEditName ? (
<div className="mb-5 flex items-center text-2xl font-bold">
<div className="mb-5 flex items-center text-xl font-bold sm:text-2xl">
{database.name}
{isCanManageDBs && (
@@ -162,7 +162,7 @@ export const DatabaseConfigComponent = ({
<div>
<div className="flex items-center">
<Input
className="max-w-[250px]"
className="max-w-full sm:max-w-[250px]"
value={editDatabase?.name}
onChange={(e) => {
if (!editDatabase) return;
@@ -174,7 +174,7 @@ export const DatabaseConfigComponent = ({
size="large"
/>
<div className="ml-1 flex items-center">
<div className="ml-1 flex flex-shrink-0 items-center">
<Button
type="text"
className="flex h-6 w-6 items-center justify-center p-0"
@@ -184,7 +184,7 @@ export const DatabaseConfigComponent = ({
setEditDatabase(undefined);
}}
>
<CloseOutlined className="text-gray-500" />
<CloseOutlined className="text-gray-500 dark:text-gray-400" />
</Button>
</div>
</div>
@@ -204,7 +204,7 @@ export const DatabaseConfigComponent = ({
)}
{database.lastBackupErrorMessage && (
<div className="max-w-[400px] rounded border border-red-600 px-3 py-3">
<div className="mb-4 max-w-full rounded border border-red-600 px-3 py-3 sm:max-w-[400px]">
<div className="mt-1 flex items-center text-sm font-bold text-red-600">
<InfoCircleOutlined className="mr-2" style={{ color: 'red' }} />
Last backup error
@@ -216,7 +216,7 @@ export const DatabaseConfigComponent = ({
{database.lastBackupErrorMessage}
</div>
<div className="mt-3 text-sm text-gray-500">
<div className="mt-3 text-sm text-gray-500 dark:text-gray-400">
To clean this error (choose any):
<ul>
<li>- test connection via button below (even if you updated settings);</li>
@@ -226,8 +226,8 @@ export const DatabaseConfigComponent = ({
</div>
)}
<div className="flex flex-wrap gap-10">
<div className="w-[400px]">
<div className="flex flex-col gap-6 lg:flex-row lg:flex-wrap lg:gap-10">
<div className="w-full lg:w-[400px]">
<div className="mt-5 flex items-center font-bold">
<div>Database settings</div>
@@ -260,7 +260,7 @@ export const DatabaseConfigComponent = ({
</div>
</div>
<div className="w-[400px]">
<div className="w-full lg:w-[400px]">
<div className="mt-5 flex items-center font-bold">
<div>Backup config</div>
@@ -299,8 +299,8 @@ export const DatabaseConfigComponent = ({
</div>
</div>
<div className="flex flex-wrap gap-10">
<div className="w-[400px]">
<div className="flex flex-col gap-6 lg:flex-row lg:flex-wrap lg:gap-10">
<div className="w-full lg:w-[400px]">
<div className="mt-5 flex items-center font-bold">
<div>Healthcheck settings</div>
@@ -328,7 +328,7 @@ export const DatabaseConfigComponent = ({
</div>
</div>
<div className="w-[400px]">
<div className="w-full lg:w-[400px]">
<div className="mt-5 flex items-center font-bold">
<div>Notifiers settings</div>
@@ -366,11 +366,10 @@ export const DatabaseConfigComponent = ({
</div>
{!isEditDatabaseSpecificDataSettings && (
<div className="mt-10">
<div className="mt-10 flex flex-col gap-2 sm:flex-row sm:gap-0">
<Button
type="primary"
className="mr-1"
ghost
className="w-full sm:mr-1 sm:w-auto"
onClick={testConnection}
loading={isTestingConnection}
disabled={isTestingConnection}
@@ -380,8 +379,7 @@ export const DatabaseConfigComponent = ({
<Button
type="primary"
className="mr-1"
ghost
className="w-full sm:mr-1 sm:w-auto"
onClick={copyDatabase}
loading={isCopying}
disabled={isCopying}
@@ -391,6 +389,7 @@ export const DatabaseConfigComponent = ({
<Button
type="primary"
className="w-full sm:w-auto"
danger
onClick={() => setIsShowRemoveConfirm(true)}
ghost

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import { databaseApi } from '../../../entity/databases';
import type { Database } from '../../../entity/databases';
import type { WorkspaceResponse } from '../../../entity/workspaces';
import { useIsMobile } from '../../../shared/hooks';
import { CreateDatabaseComponent } from './CreateDatabaseComponent';
import { DatabaseCardComponent } from './DatabaseCardComponent';
import { DatabaseComponent } from './DatabaseComponent';
@@ -17,6 +18,7 @@ interface Props {
const SELECTED_DATABASE_STORAGE_KEY = 'selectedDatabaseId';
export const DatabasesComponent = ({ contentHeight, workspace, isCanManageDBs }: Props) => {
const isMobile = useIsMobile();
const [isLoading, setIsLoading] = useState(true);
const [databases, setDatabases] = useState<Database[]>([]);
const [searchQuery, setSearchQuery] = useState('');
@@ -44,7 +46,8 @@ export const DatabasesComponent = ({ contentHeight, workspace, isCanManageDBs }:
setDatabases(databases);
if (selectDatabaseId) {
updateSelectedDatabaseId(selectDatabaseId);
} else if (!selectedDatabaseId && !isSilent) {
} else if (!selectedDatabaseId && !isSilent && !isMobile) {
// On desktop, auto-select a database; on mobile, keep it unselected
const savedDatabaseId = localStorage.getItem(
`${SELECTED_DATABASE_STORAGE_KEY}_${workspace.id}`,
);
@@ -87,66 +90,86 @@ export const DatabasesComponent = ({ contentHeight, workspace, isCanManageDBs }:
database.name.toLowerCase().includes(searchQuery.toLowerCase()),
);
// On mobile, show either the list or the database details
const showDatabaseList = !isMobile || !selectedDatabaseId;
const showDatabaseDetails = selectedDatabaseId && (!isMobile || selectedDatabaseId);
return (
<>
<div className="flex grow">
<div
className="mx-3 w-[250px] min-w-[250px] overflow-y-auto pr-2"
style={{ height: contentHeight }}
>
{databases.length >= 5 && (
<>
{isCanManageDBs && addDatabaseButton}
{showDatabaseList && (
<div
className="w-full overflow-y-auto md:mx-3 md:w-[250px] md:min-w-[250px] md:pr-2"
style={{ height: contentHeight }}
>
{databases.length >= 5 && (
<>
{isCanManageDBs && addDatabaseButton}
<div className="mb-2">
<input
placeholder="Search database"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full border-b border-gray-300 p-1 text-gray-500 outline-none"
/>
</div>
</>
)}
{filteredDatabases.length > 0
? filteredDatabases.map((database) => (
<DatabaseCardComponent
key={database.id}
database={database}
selectedDatabaseId={selectedDatabaseId}
setSelectedDatabaseId={updateSelectedDatabaseId}
/>
))
: searchQuery && (
<div className="mb-4 text-center text-sm text-gray-500">
No databases found matching &quot;{searchQuery}&quot;
<div className="mb-2">
<input
placeholder="Search database"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full border-b border-gray-300 p-1 text-gray-500 outline-none dark:text-gray-400"
/>
</div>
)}
</>
)}
{databases.length < 5 && isCanManageDBs && addDatabaseButton}
{filteredDatabases.length > 0
? filteredDatabases.map((database) => (
<DatabaseCardComponent
key={database.id}
database={database}
selectedDatabaseId={selectedDatabaseId}
setSelectedDatabaseId={updateSelectedDatabaseId}
/>
))
: searchQuery && (
<div className="mb-4 text-center text-sm text-gray-500 dark:text-gray-400">
No databases found matching &quot;{searchQuery}&quot;
</div>
)}
<div className="mx-3 text-center text-xs text-gray-500">
Database - is a thing we are backing up
{databases.length < 5 && isCanManageDBs && addDatabaseButton}
<div className="mx-3 text-center text-xs text-gray-500 dark:text-gray-400">
Database - is a thing we are backing up
</div>
</div>
</div>
)}
{selectedDatabaseId && (
<DatabaseComponent
contentHeight={contentHeight}
databaseId={selectedDatabaseId}
onDatabaseChanged={() => {
loadDatabases();
}}
onDatabaseDeleted={() => {
const remainingDatabases = databases.filter(
(database) => database.id !== selectedDatabaseId,
);
updateSelectedDatabaseId(remainingDatabases[0]?.id);
loadDatabases();
}}
isCanManageDBs={isCanManageDBs}
/>
{showDatabaseDetails && (
<div className="flex w-full flex-col md:flex-1">
{isMobile && (
<div className="mb-2">
<Button
type="default"
onClick={() => updateSelectedDatabaseId(undefined)}
className="w-full"
>
Back to databases
</Button>
</div>
)}
<DatabaseComponent
contentHeight={isMobile ? contentHeight - 50 : contentHeight}
databaseId={selectedDatabaseId}
onDatabaseChanged={() => {
loadDatabases();
}}
onDatabaseDeleted={() => {
const remainingDatabases = databases.filter(
(database) => database.id !== selectedDatabaseId,
);
updateSelectedDatabaseId(remainingDatabases[0]?.id);
loadDatabases();
}}
isCanManageDBs={isCanManageDBs}
/>
</div>
)}
</div>

View File

@@ -0,0 +1,170 @@
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"
className="!text-blue-600 dark:!text-blue-400"
>
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

@@ -93,7 +93,7 @@ export const EditDatabaseNotifiersComponent = ({
return (
<div>
<div className="mb-5 max-w-[275px] text-gray-500">
<div className="mb-5 max-w-[275px] text-gray-500 dark:text-gray-400">
Notifier - is a place where notifications will be sent (email, Slack, Telegram, etc.)
<br />
<br />
@@ -162,7 +162,7 @@ export const EditDatabaseNotifiersComponent = ({
open={isShowCreateNotifier}
onCancel={() => setShowCreateNotifier(false)}
>
<div className="my-3 max-w-[275px] text-gray-500">
<div className="my-3 max-w-[275px] text-gray-500 dark:text-gray-400">
Notifier - is a place where notifications will be sent (email, Slack, Telegram, etc.)
</div>

View File

@@ -48,10 +48,12 @@ export const EditDatabaseSpecificDataComponent = ({
const [isConnectionTested, setIsConnectionTested] = useState(false);
const [isTestingConnection, setIsTestingConnection] = useState(false);
const [isConnectionFailed, setIsConnectionFailed] = useState(false);
const testConnection = async () => {
if (!editingDatabase) return;
setIsTestingConnection(true);
setIsConnectionFailed(false);
try {
await databaseApi.testDatabaseConnectionDirect(editingDatabase);
@@ -61,6 +63,7 @@ export const EditDatabaseSpecificDataComponent = ({
description: 'You can continue with the next step',
});
} catch (e) {
setIsConnectionFailed(true);
alert((e as Error).message);
}
@@ -89,6 +92,7 @@ export const EditDatabaseSpecificDataComponent = ({
setIsSaving(false);
setIsConnectionTested(false);
setIsTestingConnection(false);
setIsConnectionFailed(false);
setEditingDatabase({ ...database });
}, [database]);
@@ -177,12 +181,13 @@ export const EditDatabaseSpecificDataComponent = ({
{isLocalhostDb && (
<div className="mb-1 flex">
<div className="min-w-[150px]" />
<div className="max-w-[200px] text-xs text-gray-500">
<div className="max-w-[200px] text-xs text-gray-500 dark:text-gray-400">
Please{' '}
<a
href="https://postgresus.com/faq#how-to-backup-localhost"
target="_blank"
rel="noreferrer"
className="!text-blue-600 dark:!text-blue-400"
>
read this document
</a>{' '}
@@ -326,6 +331,13 @@ export const EditDatabaseSpecificDataComponent = ({
</Button>
)}
</div>
{isConnectionFailed && (
<div className="mt-3 text-sm text-gray-500 dark:text-gray-400">
If your database uses IP whitelist, make sure Postgresus server IP is added to the allowed
list.
</div>
)}
</div>
);
};

View File

@@ -23,7 +23,7 @@ export const ShowDatabaseNotifiersComponent = ({ database }: Props) => {
</div>
))
) : (
<div className="text-gray-500">No notifiers configured</div>
<div className="text-gray-500 dark:text-gray-400">No notifiers configured</div>
)}
</div>
</div>

View File

@@ -118,16 +118,16 @@ export const HealthckeckAttemptsComponent = ({ database }: Props) => {
}
return (
<div className="w-full rounded-tr-md rounded-br-md rounded-bl-md bg-white p-5 shadow">
<h2 className="text-xl font-bold">Healthcheck attempts</h2>
<div className="w-full rounded-tr-md rounded-br-md rounded-bl-md bg-white p-3 shadow sm:p-5 dark:bg-gray-800">
<h2 className="text-lg font-bold sm:text-xl">Healthcheck attempts</h2>
<div className="mt-4 flex items-center gap-2">
<span className="mr-2 text-sm font-medium">Period</span>
<div className="mt-3 flex flex-col gap-2 sm:mt-4 sm:flex-row sm:items-center">
<span className="text-sm font-medium sm:mr-2">Period</span>
<Select
size="small"
value={period}
onChange={(value) => setPeriod(value)}
style={{ width: 120 }}
className="w-full sm:w-[120px]"
options={[
{ value: 'today', label: 'Today' },
{ value: '7d', label: '7 days' },
@@ -137,7 +137,7 @@ export const HealthckeckAttemptsComponent = ({ database }: Props) => {
/>
</div>
<div className="mt-5" />
<div className="mt-4 sm:mt-5" />
{isLoading ? (
<div className="flex justify-center">

View File

@@ -41,31 +41,31 @@ export const ShowHealthcheckConfigComponent = ({ databaseId }: Props) => {
<div className="space-y-4">
<div className="mb-1 flex items-center">
<div className="min-w-[180px]">Is health check enabled</div>
<div className="w-[250px]">{healthcheckConfig.isHealthcheckEnabled ? 'Yes' : 'No'}</div>
<div>{healthcheckConfig.isHealthcheckEnabled ? 'Yes' : 'No'}</div>
</div>
{healthcheckConfig.isHealthcheckEnabled && (
<>
<div className="mb-1 flex items-center">
<div className="min-w-[180px]">Notify when unavailable</div>
<div className="w-[250px]">
<div className="lg:w-[200px]">
{healthcheckConfig.isSentNotificationWhenUnavailable ? 'Yes' : 'No'}
</div>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[180px]">Check interval (minutes)</div>
<div className="w-[250px]">{healthcheckConfig.intervalMinutes}</div>
<div className="lg:w-[200px]">{healthcheckConfig.intervalMinutes}</div>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[180px]">Attempts before down</div>
<div className="w-[250px]">{healthcheckConfig.attemptsBeforeConcideredAsDown}</div>
<div className="lg:w-[200px]">{healthcheckConfig.attemptsBeforeConcideredAsDown}</div>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[180px]">Store attempts (days)</div>
<div className="w-[250px]">{healthcheckConfig.storeAttemptsDays}</div>
<div className="lg:w-[200px]">{healthcheckConfig.storeAttemptsDays}</div>
</div>
</>
)}

View File

@@ -17,13 +17,13 @@ export const NotifierCardComponent = ({
}: Props) => {
return (
<div
className={`mb-3 cursor-pointer rounded p-3 shadow ${selectedNotifierId === notifier.id ? 'bg-blue-100' : 'bg-white'}`}
className={`mb-3 cursor-pointer rounded p-3 shadow ${selectedNotifierId === notifier.id ? 'bg-blue-100 dark:bg-blue-800' : 'bg-white dark:bg-gray-800'}`}
onClick={() => setSelectedNotifierId(notifier.id)}
>
<div className="mb-1 font-bold">{notifier.name}</div>
<div className="flex items-center">
<div className="text-sm text-gray-500">
<div className="text-sm text-gray-500 dark:text-gray-400">
Notify to {getNotifierNameFromType(notifier.notifierType)}
</div>
@@ -35,7 +35,7 @@ export const NotifierCardComponent = ({
</div>
{notifier.lastSendError && (
<div className="mt-1 flex items-center text-sm text-red-600 underline">
<div className="mt-1 flex items-center text-sm text-red-600 underline dark:text-red-400">
<InfoCircleOutlined className="mr-1" style={{ color: 'red' }} />
Has send error
</div>

View File

@@ -124,7 +124,7 @@ export const NotifierComponent = ({
return (
<div className="w-full">
<div className="grow overflow-y-auto rounded bg-white p-5 shadow">
<div className="grow overflow-y-auto rounded bg-white p-5 shadow dark:bg-gray-800">
{!notifier ? (
<div className="mt-10 flex justify-center">
<Spin />
@@ -166,7 +166,7 @@ export const NotifierComponent = ({
setEditNotifier(undefined);
}}
>
<CloseOutlined className="text-gray-500" />
<CloseOutlined className="text-gray-500 dark:text-gray-400" />
</Button>
</div>
</div>
@@ -198,7 +198,7 @@ export const NotifierComponent = ({
{notifier.lastSendError}
</div>
<div className="mt-3 text-sm break-words whitespace-pre-wrap text-gray-500">
<div className="mt-3 text-sm break-words whitespace-pre-wrap text-gray-500 dark:text-gray-400">
To clean this error (choose any):
<ul>
<li>
@@ -246,7 +246,6 @@ export const NotifierComponent = ({
<Button
type="primary"
className="mr-1"
ghost
onClick={sendTestNotification}
loading={isSendingTestNotification}
disabled={isSendingTestNotification}

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import { notifierApi } from '../../../entity/notifiers';
import type { Notifier } from '../../../entity/notifiers';
import type { WorkspaceResponse } from '../../../entity/workspaces';
import { useIsMobile } from '../../../shared/hooks';
import { NotifierCardComponent } from './NotifierCardComponent';
import { NotifierComponent } from './NotifierComponent';
import { EditNotifierComponent } from './edit/EditNotifierComponent';
@@ -14,21 +15,47 @@ interface Props {
isCanManageNotifiers: boolean;
}
const SELECTED_NOTIFIER_STORAGE_KEY = 'selectedNotifierId';
export const NotifiersComponent = ({ contentHeight, workspace, isCanManageNotifiers }: Props) => {
const isMobile = useIsMobile();
const [isLoading, setIsLoading] = useState(true);
const [notifiers, setNotifiers] = useState<Notifier[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [isShowAddNotifier, setIsShowAddNotifier] = useState(false);
const [selectedNotifierId, setSelectedNotifierId] = useState<string | undefined>(undefined);
const loadNotifiers = () => {
setIsLoading(true);
const updateSelectedNotifierId = (notifierId: string | undefined) => {
setSelectedNotifierId(notifierId);
if (notifierId) {
localStorage.setItem(`${SELECTED_NOTIFIER_STORAGE_KEY}_${workspace.id}`, notifierId);
} else {
localStorage.removeItem(`${SELECTED_NOTIFIER_STORAGE_KEY}_${workspace.id}`);
}
};
const loadNotifiers = (isSilent = false, selectNotifierId?: string) => {
if (!isSilent) {
setIsLoading(true);
}
notifierApi
.getNotifiers(workspace.id)
.then((notifiers) => {
setNotifiers(notifiers);
if (!selectedNotifierId) {
setSelectedNotifierId(notifiers[0]?.id);
if (selectNotifierId) {
updateSelectedNotifierId(selectNotifierId);
} else if (!selectedNotifierId && !isSilent && !isMobile) {
// On desktop, auto-select a notifier; on mobile, keep it unselected
const savedNotifierId = localStorage.getItem(
`${SELECTED_NOTIFIER_STORAGE_KEY}_${workspace.id}`,
);
const notifierToSelect =
savedNotifierId && notifiers.some((n) => n.id === savedNotifierId)
? savedNotifierId
: notifiers[0]?.id;
updateSelectedNotifierId(notifierToSelect);
}
})
.catch((e) => alert(e.message))
@@ -37,6 +64,12 @@ export const NotifiersComponent = ({ contentHeight, workspace, isCanManageNotifi
useEffect(() => {
loadNotifiers();
const interval = setInterval(() => {
loadNotifiers(true);
}, 5 * 60_000);
return () => clearInterval(interval);
}, []);
if (isLoading) {
@@ -53,45 +86,89 @@ export const NotifiersComponent = ({ contentHeight, workspace, isCanManageNotifi
</Button>
);
const filteredNotifiers = notifiers.filter((notifier) =>
notifier.name.toLowerCase().includes(searchQuery.toLowerCase()),
);
// On mobile, show either the list or the notifier details
const showNotifierList = !isMobile || !selectedNotifierId;
const showNotifierDetails = selectedNotifierId && (!isMobile || selectedNotifierId);
return (
<>
<div className="flex grow">
<div
className="mx-3 w-[250px] min-w-[250px] overflow-y-auto"
style={{ height: contentHeight }}
>
{notifiers.length >= 5 && isCanManageNotifiers && addNotifierButton}
{showNotifierList && (
<div
className="w-full overflow-y-auto md:mx-3 md:w-[250px] md:min-w-[250px] md:pr-2"
style={{ height: contentHeight }}
>
{notifiers.length >= 5 && (
<>
{isCanManageNotifiers && addNotifierButton}
{notifiers.map((notifier) => (
<NotifierCardComponent
key={notifier.id}
notifier={notifier}
selectedNotifierId={selectedNotifierId}
setSelectedNotifierId={setSelectedNotifierId}
/>
))}
<div className="mb-2">
<input
placeholder="Search notifier"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full border-b border-gray-300 p-1 text-gray-500 outline-none dark:text-gray-400"
/>
</div>
</>
)}
{notifiers.length < 5 && isCanManageNotifiers && addNotifierButton}
{filteredNotifiers.length > 0
? filteredNotifiers.map((notifier) => (
<NotifierCardComponent
key={notifier.id}
notifier={notifier}
selectedNotifierId={selectedNotifierId}
setSelectedNotifierId={updateSelectedNotifierId}
/>
))
: searchQuery && (
<div className="mb-4 text-center text-sm text-gray-500 dark:text-gray-400">
No notifiers found matching &quot;{searchQuery}&quot;
</div>
)}
<div className="mx-3 text-center text-xs text-gray-500">
Notifier - is a place where notifications will be sent (email, Slack, Telegram, etc.)
{notifiers.length < 5 && isCanManageNotifiers && addNotifierButton}
<div className="mx-3 text-center text-xs text-gray-500 dark:text-gray-400">
Notifier - is a place where notifications will be sent (email, Slack, Telegram, etc.)
</div>
</div>
</div>
)}
{selectedNotifierId && (
<NotifierComponent
notifierId={selectedNotifierId}
onNotifierChanged={() => {
loadNotifiers();
}}
onNotifierDeleted={() => {
loadNotifiers();
setSelectedNotifierId(
notifiers.filter((notifier) => notifier.id !== selectedNotifierId)[0]?.id,
);
}}
isCanManageNotifiers={isCanManageNotifiers}
/>
{showNotifierDetails && (
<div className="flex w-full flex-col md:flex-1">
{isMobile && (
<div className="mb-2">
<Button
type="default"
onClick={() => updateSelectedNotifierId(undefined)}
className="w-full"
>
Back to notifiers
</Button>
</div>
)}
<NotifierComponent
notifierId={selectedNotifierId}
onNotifierChanged={() => {
loadNotifiers();
}}
onNotifierDeleted={() => {
const remainingNotifiers = notifiers.filter(
(notifier) => notifier.id !== selectedNotifierId,
);
updateSelectedNotifierId(remainingNotifiers[0]?.id);
loadNotifiers();
}}
isCanManageNotifiers={isCanManageNotifiers}
/>
</div>
)}
</div>
@@ -102,7 +179,7 @@ export const NotifiersComponent = ({ contentHeight, workspace, isCanManageNotifi
open={isShowAddNotifier}
onCancel={() => setIsShowAddNotifier(false)}
>
<div className="my-3 max-w-[250px] text-gray-500">
<div className="my-3 max-w-[250px] text-gray-500 dark:text-gray-400">
Notifier - is a place where notifications will be sent (email, Slack, Telegram, etc.)
</div>
@@ -111,8 +188,8 @@ export const NotifiersComponent = ({ contentHeight, workspace, isCanManageNotifi
isShowName
isShowClose={false}
onClose={() => setIsShowAddNotifier(false)}
onChanged={() => {
loadNotifiers();
onChanged={(notifier) => {
loadNotifiers(false, notifier.id);
setIsShowAddNotifier(false);
}}
/>

Some files were not shown because too many files have changed in this diff Show More