Skip to content

Commit a809dc8

Browse files
FEATURE (protection): Do not expose sensetive data of databases, notifiers and storages from API + make backups lazy loaded
1 parent bd053b5 commit a809dc8

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

53 files changed

+1470
-197
lines changed

backend/internal/features/backups/backups/controller.go

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@ func (c *BackupController) RegisterRoutes(router *gin.RouterGroup) {
2323

2424
// GetBackups
2525
// @Summary Get backups for a database
26-
// @Description Get all backups for the specified database
26+
// @Description Get paginated backups for the specified database
2727
// @Tags backups
2828
// @Produce json
2929
// @Param database_id query string true "Database ID"
30-
// @Success 200 {array} Backup
30+
// @Param limit query int false "Number of items per page" default(10)
31+
// @Param offset query int false "Offset for pagination" default(0)
32+
// @Success 200 {object} GetBackupsResponse
3133
// @Failure 400
3234
// @Failure 401
3335
// @Failure 500
@@ -39,25 +41,25 @@ func (c *BackupController) GetBackups(ctx *gin.Context) {
3941
return
4042
}
4143

42-
databaseIDStr := ctx.Query("database_id")
43-
if databaseIDStr == "" {
44-
ctx.JSON(http.StatusBadRequest, gin.H{"error": "database_id query parameter is required"})
44+
var request GetBackupsRequest
45+
if err := ctx.ShouldBindQuery(&request); err != nil {
46+
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
4547
return
4648
}
4749

48-
databaseID, err := uuid.Parse(databaseIDStr)
50+
databaseID, err := uuid.Parse(request.DatabaseID)
4951
if err != nil {
5052
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid database_id"})
5153
return
5254
}
5355

54-
backups, err := c.backupService.GetBackups(user, databaseID)
56+
response, err := c.backupService.GetBackups(user, databaseID, request.Limit, request.Offset)
5557
if err != nil {
5658
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
5759
return
5860
}
5961

60-
ctx.JSON(http.StatusOK, backups)
62+
ctx.JSON(http.StatusOK, response)
6163
}
6264

6365
// MakeBackup

backend/internal/features/backups/backups/controller_test.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,11 @@ func Test_GetBackups_PermissionsEnforced(t *testing.T) {
102102
)
103103

104104
if tt.expectSuccess {
105-
var backups []*Backup
106-
err := json.Unmarshal(testResp.Body, &backups)
105+
var response GetBackupsResponse
106+
err := json.Unmarshal(testResp.Body, &response)
107107
assert.NoError(t, err)
108-
assert.GreaterOrEqual(t, len(backups), 1)
108+
assert.GreaterOrEqual(t, len(response.Backups), 1)
109+
assert.GreaterOrEqual(t, response.Total, int64(1))
109110
} else {
110111
assert.Contains(t, string(testResp.Body), "insufficient permissions")
111112
}
@@ -329,9 +330,9 @@ func Test_DeleteBackup_PermissionsEnforced(t *testing.T) {
329330
ownerUser, err := userService.GetUserFromToken(owner.Token)
330331
assert.NoError(t, err)
331332

332-
backups, err := GetBackupService().GetBackups(ownerUser, database.ID)
333+
response, err := GetBackupService().GetBackups(ownerUser, database.ID, 10, 0)
333334
assert.NoError(t, err)
334-
assert.Equal(t, 0, len(backups))
335+
assert.Equal(t, 0, len(response.Backups))
335336
}
336337
})
337338
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package backups
2+
3+
type GetBackupsRequest struct {
4+
DatabaseID string `form:"database_id" binding:"required"`
5+
Limit int `form:"limit"`
6+
Offset int `form:"offset"`
7+
}
8+
9+
type GetBackupsResponse struct {
10+
Backups []*Backup `json:"backups"`
11+
Total int64 `json:"total"`
12+
Limit int `json:"limit"`
13+
Offset int `json:"offset"`
14+
}

backend/internal/features/backups/backups/repository.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,3 +195,38 @@ func (r *BackupRepository) FindBackupsBeforeDate(
195195

196196
return backups, nil
197197
}
198+
199+
func (r *BackupRepository) FindByDatabaseIDWithPagination(
200+
databaseID uuid.UUID,
201+
limit, offset int,
202+
) ([]*Backup, error) {
203+
var backups []*Backup
204+
205+
if err := storage.
206+
GetDb().
207+
Preload("Database").
208+
Preload("Storage").
209+
Where("database_id = ?", databaseID).
210+
Order("created_at DESC").
211+
Limit(limit).
212+
Offset(offset).
213+
Find(&backups).Error; err != nil {
214+
return nil, err
215+
}
216+
217+
return backups, nil
218+
}
219+
220+
func (r *BackupRepository) CountByDatabaseID(databaseID uuid.UUID) (int64, error) {
221+
var count int64
222+
223+
if err := storage.
224+
GetDb().
225+
Model(&Backup{}).
226+
Where("database_id = ?", databaseID).
227+
Count(&count).Error; err != nil {
228+
return 0, err
229+
}
230+
231+
return count, nil
232+
}

backend/internal/features/backups/backups/service.go

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,8 @@ func (s *BackupService) MakeBackupWithAuth(
9393
func (s *BackupService) GetBackups(
9494
user *users_models.User,
9595
databaseID uuid.UUID,
96-
) ([]*Backup, error) {
96+
limit, offset int,
97+
) (*GetBackupsResponse, error) {
9798
database, err := s.databaseService.GetDatabaseByID(databaseID)
9899
if err != nil {
99100
return nil, err
@@ -111,12 +112,29 @@ func (s *BackupService) GetBackups(
111112
return nil, errors.New("insufficient permissions to access backups for this database")
112113
}
113114

114-
backups, err := s.backupRepository.FindByDatabaseID(databaseID)
115+
if limit <= 0 {
116+
limit = 10
117+
}
118+
if offset < 0 {
119+
offset = 0
120+
}
121+
122+
backups, err := s.backupRepository.FindByDatabaseIDWithPagination(databaseID, limit, offset)
123+
if err != nil {
124+
return nil, err
125+
}
126+
127+
total, err := s.backupRepository.CountByDatabaseID(databaseID)
115128
if err != nil {
116129
return nil, err
117130
}
118131

119-
return backups, nil
132+
return &GetBackupsResponse{
133+
Backups: backups,
134+
Total: total,
135+
Limit: limit,
136+
Offset: offset,
137+
}, nil
120138
}
121139

122140
func (s *BackupService) DeleteBackup(

backend/internal/features/backups/config/service.go

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -82,19 +82,6 @@ func (s *BackupConfigService) SaveBackupConfig(
8282
}
8383
}
8484

85-
if !backupConfig.IsBackupsEnabled && existingConfig.StorageID != nil {
86-
if err := s.dbStorageChangeListener.OnBeforeBackupsStorageChange(
87-
backupConfig.DatabaseID,
88-
); err != nil {
89-
return nil, err
90-
}
91-
92-
// we clear storage for disabled backups to allow
93-
// storage removal for unused storages
94-
backupConfig.Storage = nil
95-
backupConfig.StorageID = nil
96-
}
97-
9885
return s.backupConfigRepository.Save(backupConfig)
9986
}
10087

backend/internal/features/databases/controller_test.go

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -768,3 +768,161 @@ func createTestDatabaseViaAPI(
768768

769769
return &database
770770
}
771+
772+
func Test_DatabaseSensitiveDataLifecycle_AllTypes(t *testing.T) {
773+
testCases := []struct {
774+
name string
775+
databaseType DatabaseType
776+
createDatabase func(workspaceID uuid.UUID) *Database
777+
updateDatabase func(workspaceID uuid.UUID, databaseID uuid.UUID) *Database
778+
verifySensitiveData func(t *testing.T, database *Database)
779+
verifyHiddenData func(t *testing.T, database *Database)
780+
}{
781+
{
782+
name: "PostgreSQL Database",
783+
databaseType: DatabaseTypePostgres,
784+
createDatabase: func(workspaceID uuid.UUID) *Database {
785+
testDbName := "test_db"
786+
return &Database{
787+
WorkspaceID: &workspaceID,
788+
Name: "Test PostgreSQL Database",
789+
Type: DatabaseTypePostgres,
790+
Postgresql: &postgresql.PostgresqlDatabase{
791+
Version: tools.PostgresqlVersion16,
792+
Host: "localhost",
793+
Port: 5432,
794+
Username: "postgres",
795+
Password: "original-password-secret",
796+
Database: &testDbName,
797+
},
798+
}
799+
},
800+
updateDatabase: func(workspaceID uuid.UUID, databaseID uuid.UUID) *Database {
801+
testDbName := "updated_test_db"
802+
return &Database{
803+
ID: databaseID,
804+
WorkspaceID: &workspaceID,
805+
Name: "Updated PostgreSQL Database",
806+
Type: DatabaseTypePostgres,
807+
Postgresql: &postgresql.PostgresqlDatabase{
808+
Version: tools.PostgresqlVersion17,
809+
Host: "updated-host",
810+
Port: 5433,
811+
Username: "updated_user",
812+
Password: "",
813+
Database: &testDbName,
814+
},
815+
}
816+
},
817+
verifySensitiveData: func(t *testing.T, database *Database) {
818+
assert.Equal(t, "original-password-secret", database.Postgresql.Password)
819+
},
820+
verifyHiddenData: func(t *testing.T, database *Database) {
821+
assert.Equal(t, "", database.Postgresql.Password)
822+
},
823+
},
824+
}
825+
826+
for _, tc := range testCases {
827+
t.Run(tc.name, func(t *testing.T) {
828+
router := createTestRouter()
829+
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
830+
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
831+
832+
// Phase 1: Create database with sensitive data
833+
initialDatabase := tc.createDatabase(workspace.ID)
834+
var createdDatabase Database
835+
test_utils.MakePostRequestAndUnmarshal(
836+
t,
837+
router,
838+
"/api/v1/databases/create",
839+
"Bearer "+owner.Token,
840+
*initialDatabase,
841+
http.StatusCreated,
842+
&createdDatabase,
843+
)
844+
assert.NotEmpty(t, createdDatabase.ID)
845+
assert.Equal(t, initialDatabase.Name, createdDatabase.Name)
846+
847+
// Phase 2: Read via service - sensitive data should be hidden
848+
var retrievedDatabase Database
849+
test_utils.MakeGetRequestAndUnmarshal(
850+
t,
851+
router,
852+
fmt.Sprintf("/api/v1/databases/%s", createdDatabase.ID.String()),
853+
"Bearer "+owner.Token,
854+
http.StatusOK,
855+
&retrievedDatabase,
856+
)
857+
tc.verifyHiddenData(t, &retrievedDatabase)
858+
assert.Equal(t, initialDatabase.Name, retrievedDatabase.Name)
859+
860+
// Phase 3: Update with non-sensitive changes only (sensitive fields empty)
861+
updatedDatabase := tc.updateDatabase(workspace.ID, createdDatabase.ID)
862+
var updateResponse Database
863+
test_utils.MakePostRequestAndUnmarshal(
864+
t,
865+
router,
866+
"/api/v1/databases/update",
867+
"Bearer "+owner.Token,
868+
*updatedDatabase,
869+
http.StatusOK,
870+
&updateResponse,
871+
)
872+
873+
// Phase 4: Retrieve directly from repository to verify sensitive data preservation
874+
repository := &DatabaseRepository{}
875+
databaseFromDB, err := repository.FindByID(createdDatabase.ID)
876+
assert.NoError(t, err)
877+
878+
// Verify original sensitive data is still present in DB
879+
tc.verifySensitiveData(t, databaseFromDB)
880+
881+
// Verify non-sensitive fields were updated in DB
882+
assert.Equal(t, updatedDatabase.Name, databaseFromDB.Name)
883+
884+
// Phase 5: Additional verification - Check via GET that data is still hidden
885+
var finalRetrieved Database
886+
test_utils.MakeGetRequestAndUnmarshal(
887+
t,
888+
router,
889+
fmt.Sprintf("/api/v1/databases/%s", createdDatabase.ID.String()),
890+
"Bearer "+owner.Token,
891+
http.StatusOK,
892+
&finalRetrieved,
893+
)
894+
tc.verifyHiddenData(t, &finalRetrieved)
895+
896+
// Phase 6: Verify GetDatabasesByWorkspace also hides sensitive data
897+
var workspaceDatabases []Database
898+
test_utils.MakeGetRequestAndUnmarshal(
899+
t,
900+
router,
901+
fmt.Sprintf("/api/v1/databases?workspace_id=%s", workspace.ID.String()),
902+
"Bearer "+owner.Token,
903+
http.StatusOK,
904+
&workspaceDatabases,
905+
)
906+
var foundDatabase *Database
907+
for i := range workspaceDatabases {
908+
if workspaceDatabases[i].ID == createdDatabase.ID {
909+
foundDatabase = &workspaceDatabases[i]
910+
break
911+
}
912+
}
913+
assert.NotNil(t, foundDatabase, "Database should be found in workspace databases list")
914+
tc.verifyHiddenData(t, foundDatabase)
915+
916+
// Clean up: Delete database before removing workspace
917+
test_utils.MakeDeleteRequest(
918+
t,
919+
router,
920+
fmt.Sprintf("/api/v1/databases/%s", createdDatabase.ID.String()),
921+
"Bearer "+owner.Token,
922+
http.StatusNoContent,
923+
)
924+
925+
workspaces_testing.RemoveTestWorkspace(workspace, router)
926+
})
927+
}
928+
}

backend/internal/features/databases/databases/postgresql/model.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,23 @@ func (p *PostgresqlDatabase) TestConnection(logger *slog.Logger) error {
6666
return testSingleDatabaseConnection(logger, ctx, p)
6767
}
6868

69+
func (p *PostgresqlDatabase) HideSensitiveData() {
70+
p.Password = ""
71+
}
72+
73+
func (p *PostgresqlDatabase) Update(incoming *PostgresqlDatabase) {
74+
p.Version = incoming.Version
75+
p.Host = incoming.Host
76+
p.Port = incoming.Port
77+
p.Username = incoming.Username
78+
p.Database = incoming.Database
79+
p.IsHttps = incoming.IsHttps
80+
81+
if incoming.Password != "" {
82+
p.Password = incoming.Password
83+
}
84+
}
85+
6986
// testSingleDatabaseConnection tests connection to a specific database for pg_dump
7087
func testSingleDatabaseConnection(
7188
logger *slog.Logger,

backend/internal/features/databases/interfaces.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ type DatabaseValidator interface {
1212

1313
type DatabaseConnector interface {
1414
TestConnection(logger *slog.Logger) error
15+
16+
HideSensitiveData()
1517
}
1618

1719
type DatabaseCreationListener interface {

0 commit comments

Comments
 (0)