diff --git a/.gitignore b/.gitignore index 1987e85..9c96f2a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ postgresus-data/ pgdata/ docker-compose.yml node_modules/ -.idea \ No newline at end of file +.idea +/articles \ No newline at end of file diff --git a/backend/internal/features/databases/controller.go b/backend/internal/features/databases/controller.go index 0e4f30a..eae5a7c 100644 --- a/backend/internal/features/databases/controller.go +++ b/backend/internal/features/databases/controller.go @@ -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, + }) +} diff --git a/backend/internal/features/databases/databases/postgresql/model.go b/backend/internal/features/databases/databases/postgresql/model.go index b8b5a4d..e51a167 100644 --- a/backend/internal/features/databases/databases/postgresql/model.go +++ b/backend/internal/features/databases/databases/postgresql/model.go @@ -8,6 +8,7 @@ import ( "postgresus-backend/internal/util/encryption" "postgresus-backend/internal/util/tools" "regexp" + "strings" "time" "github.com/google/uuid" @@ -106,6 +107,384 @@ func (p *PostgresqlDatabase) EncryptSensitiveFields( return nil } +// IsUserReadOnly checks if the database user has read-only privileges. +// +// This method performs a comprehensive security check by examining: +// - Role-level attributes (superuser, createrole, createdb) +// - Database-level privileges (CREATE, TEMP) +// - Table-level write permissions (INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER) +// +// A user is considered read-only only if they have ZERO write privileges +// across all three levels. This ensures the database user follows the +// principle of least privilege for backup operations. +func (p *PostgresqlDatabase) IsUserReadOnly( + ctx context.Context, + logger *slog.Logger, + encryptor encryption.FieldEncryptor, + databaseID uuid.UUID, +) (bool, error) { + password, err := decryptPasswordIfNeeded(p.Password, encryptor, databaseID) + if err != nil { + return false, fmt.Errorf("failed to decrypt password: %w", err) + } + + connStr := buildConnectionStringForDB(p, *p.Database, password) + + conn, err := pgx.Connect(ctx, connStr) + if err != nil { + return false, fmt.Errorf("failed to connect to database: %w", err) + } + defer func() { + if closeErr := conn.Close(ctx); closeErr != nil { + logger.Error("Failed to close connection", "error", closeErr) + } + }() + + // LEVEL 1: Check role-level attributes + var isSuperuser, canCreateRole, canCreateDB bool + err = conn.QueryRow(ctx, ` + SELECT + rolsuper, + rolcreaterole, + rolcreatedb + FROM pg_roles + WHERE rolname = current_user + `).Scan(&isSuperuser, &canCreateRole, &canCreateDB) + if err != nil { + return false, fmt.Errorf("failed to check role attributes: %w", err) + } + + if isSuperuser || canCreateRole || canCreateDB { + return false, nil + } + + // LEVEL 2: Check database-level privileges + var canCreate, canTemp bool + err = conn.QueryRow(ctx, ` + SELECT + has_database_privilege(current_user, current_database(), 'CREATE') as can_create, + has_database_privilege(current_user, current_database(), 'TEMP') as can_temp + `).Scan(&canCreate, &canTemp) + if err != nil { + return false, fmt.Errorf("failed to check database privileges: %w", err) + } + + if canCreate || canTemp { + return false, nil + } + + // LEVEL 2.5: Check schema-level CREATE privileges + schemaRows, err := conn.Query(ctx, ` + SELECT DISTINCT nspname + FROM pg_namespace n + WHERE has_schema_privilege(current_user, n.nspname, 'CREATE') + AND nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + `) + if err != nil { + return false, fmt.Errorf("failed to check schema privileges: %w", err) + } + defer schemaRows.Close() + + // If user has CREATE privilege on any schema, they're not read-only + if schemaRows.Next() { + return false, nil + } + + if err := schemaRows.Err(); err != nil { + return false, fmt.Errorf("error iterating schema privileges: %w", err) + } + + // LEVEL 3: Check table-level write permissions + rows, err := conn.Query(ctx, ` + SELECT DISTINCT privilege_type + FROM information_schema.role_table_grants + WHERE grantee = current_user + AND table_schema NOT IN ('pg_catalog', 'information_schema') + `) + if err != nil { + return false, fmt.Errorf("failed to check table privileges: %w", err) + } + defer rows.Close() + + writePrivileges := map[string]bool{ + "INSERT": true, + "UPDATE": true, + "DELETE": true, + "TRUNCATE": true, + "REFERENCES": true, + "TRIGGER": true, + } + + for rows.Next() { + var privilege string + if err := rows.Scan(&privilege); err != nil { + return false, fmt.Errorf("failed to scan privilege: %w", err) + } + + if writePrivileges[privilege] { + return false, nil + } + } + + if err := rows.Err(); err != nil { + return false, fmt.Errorf("error iterating privileges: %w", err) + } + + return true, nil +} + +// CreateReadOnlyUser creates a new PostgreSQL user with read-only privileges. +// +// This method performs the following operations atomically in a single transaction: +// 1. Creates a PostgreSQL user with a UUID-based password +// 2. Grants CONNECT privilege on the database +// 3. Grants USAGE on all non-system schemas +// 4. Grants SELECT on all existing tables and sequences +// 5. Sets default privileges for future tables and sequences +// +// Security features: +// - Username format: "postgresus-{8-char-uuid}" for uniqueness +// - Password: Full UUID (36 characters) for strong entropy +// - Transaction safety: All operations rollback on any failure +// - Retry logic: Up to 3 attempts if username collision occurs +// - Pre-validation: Checks CREATEROLE privilege before starting transaction +func (p *PostgresqlDatabase) CreateReadOnlyUser( + ctx context.Context, + logger *slog.Logger, + encryptor encryption.FieldEncryptor, + databaseID uuid.UUID, +) (string, string, error) { + password, err := decryptPasswordIfNeeded(p.Password, encryptor, databaseID) + if err != nil { + return "", "", fmt.Errorf("failed to decrypt password: %w", err) + } + + connStr := buildConnectionStringForDB(p, *p.Database, password) + + conn, err := pgx.Connect(ctx, connStr) + if err != nil { + return "", "", fmt.Errorf("failed to connect to database: %w", err) + } + defer func() { + if closeErr := conn.Close(ctx); closeErr != nil { + logger.Error("Failed to close connection", "error", closeErr) + } + }() + + // Pre-validate: Check if current user can create roles + var canCreateRole, isSuperuser bool + err = conn.QueryRow(ctx, ` + SELECT rolcreaterole, rolsuper + FROM pg_roles + WHERE rolname = current_user + `).Scan(&canCreateRole, &isSuperuser) + if err != nil { + return "", "", fmt.Errorf("failed to check permissions: %w", err) + } + if !canCreateRole && !isSuperuser { + return "", "", errors.New("current database user lacks CREATEROLE privilege") + } + + // Retry logic for username collision + maxRetries := 3 + for attempt := 0; attempt < maxRetries; attempt++ { + username := fmt.Sprintf("postgresus-%s", uuid.New().String()[:8]) + newPassword := uuid.New().String() + + tx, err := conn.Begin(ctx) + if err != nil { + return "", "", fmt.Errorf("failed to begin transaction: %w", err) + } + + success := false + defer func() { + if !success { + if rollbackErr := tx.Rollback(ctx); rollbackErr != nil { + logger.Error("Failed to rollback transaction", "error", rollbackErr) + } + } + }() + + // Step 1: Create PostgreSQL user with LOGIN privilege + _, err = tx.Exec( + ctx, + fmt.Sprintf(`CREATE USER "%s" WITH PASSWORD '%s' LOGIN`, username, newPassword), + ) + if err != nil { + if err.Error() != "" && attempt < maxRetries-1 { + continue + } + return "", "", fmt.Errorf("failed to create user: %w", err) + } + + // Step 1.5: Revoke CREATE privilege from PUBLIC role on public schema + // This is necessary because all PostgreSQL users inherit CREATE privilege on the + // public schema through the PUBLIC role. This is a one-time operation that affects + // the entire database, making it more secure by default. + // Note: This only affects the public schema; other schemas are unaffected. + _, err = tx.Exec(ctx, `REVOKE CREATE ON SCHEMA public FROM PUBLIC`) + if err != nil { + logger.Error("Failed to revoke CREATE on public from PUBLIC", "error", err) + if !strings.Contains(err.Error(), "schema \"public\" does not exist") && + !strings.Contains(err.Error(), "permission denied") { + return "", "", fmt.Errorf("failed to revoke CREATE from PUBLIC: %w", err) + } + } + + // Now revoke from the specific user as well (belt and suspenders) + _, err = tx.Exec(ctx, fmt.Sprintf(`REVOKE CREATE ON SCHEMA public FROM "%s"`, username)) + if err != nil { + logger.Error( + "Failed to revoke CREATE on public schema from user", + "error", + err, + "username", + username, + ) + } + + // Step 2: Grant database connection privilege and revoke TEMP + _, err = tx.Exec( + ctx, + fmt.Sprintf(`GRANT CONNECT ON DATABASE %s TO "%s"`, *p.Database, username), + ) + if err != nil { + return "", "", fmt.Errorf("failed to grant connect privilege: %w", err) + } + + // Revoke TEMP privilege from PUBLIC role (like CREATE on public schema, TEMP is granted to PUBLIC by default) + _, err = tx.Exec(ctx, fmt.Sprintf(`REVOKE TEMP ON DATABASE %s FROM PUBLIC`, *p.Database)) + if err != nil { + logger.Warn("Failed to revoke TEMP from PUBLIC", "error", err) + } + + // Also revoke from the specific user (belt and suspenders) + _, err = tx.Exec( + ctx, + fmt.Sprintf(`REVOKE TEMP ON DATABASE %s FROM "%s"`, *p.Database, username), + ) + if err != nil { + logger.Warn("Failed to revoke TEMP privilege", "error", err, "username", username) + } + + // Step 3: Discover all user-created schemas + rows, err := tx.Query(ctx, ` + SELECT schema_name + FROM information_schema.schemata + WHERE schema_name NOT IN ('pg_catalog', 'information_schema') + `) + if err != nil { + return "", "", fmt.Errorf("failed to get schemas: %w", err) + } + + var schemas []string + for rows.Next() { + var schema string + if err := rows.Scan(&schema); err != nil { + rows.Close() + return "", "", fmt.Errorf("failed to scan schema: %w", err) + } + schemas = append(schemas, schema) + } + rows.Close() + + if err := rows.Err(); err != nil { + return "", "", fmt.Errorf("error iterating schemas: %w", err) + } + + // Step 4: Grant USAGE on each schema and explicitly prevent CREATE + for _, schema := range schemas { + // Revoke CREATE specifically (handles inheritance from PUBLIC role) + _, err = tx.Exec( + ctx, + fmt.Sprintf(`REVOKE CREATE ON SCHEMA "%s" FROM "%s"`, schema, username), + ) + if err != nil { + logger.Warn( + "Failed to revoke CREATE on schema", + "error", + err, + "schema", + schema, + "username", + username, + ) + } + + // Grant only USAGE (not CREATE) + _, err = tx.Exec( + ctx, + fmt.Sprintf(`GRANT USAGE ON SCHEMA "%s" TO "%s"`, schema, username), + ) + if err != nil { + return "", "", fmt.Errorf("failed to grant usage on schema %s: %w", schema, err) + } + } + + // Step 5: Grant SELECT on ALL existing tables and sequences + grantSelectSQL := fmt.Sprintf(` + DO $$ + DECLARE + schema_rec RECORD; + BEGIN + FOR schema_rec IN + SELECT schema_name + FROM information_schema.schemata + WHERE schema_name NOT IN ('pg_catalog', 'information_schema') + LOOP + EXECUTE format('GRANT SELECT ON ALL TABLES IN SCHEMA %%I TO "%s"', schema_rec.schema_name); + EXECUTE format('GRANT SELECT ON ALL SEQUENCES IN SCHEMA %%I TO "%s"', schema_rec.schema_name); + END LOOP; + END $$; + `, username, username) + + _, err = tx.Exec(ctx, grantSelectSQL) + if err != nil { + return "", "", fmt.Errorf("failed to grant select on tables: %w", err) + } + + // Step 6: Set default privileges for FUTURE tables and sequences + defaultPrivilegesSQL := fmt.Sprintf(` + DO $$ + DECLARE + schema_rec RECORD; + BEGIN + FOR schema_rec IN + SELECT schema_name + FROM information_schema.schemata + WHERE schema_name NOT IN ('pg_catalog', 'information_schema') + LOOP + EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %%I GRANT SELECT ON TABLES TO "%s"', schema_rec.schema_name); + EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %%I GRANT SELECT ON SEQUENCES TO "%s"', schema_rec.schema_name); + END LOOP; + END $$; + `, username, username) + + _, err = tx.Exec(ctx, defaultPrivilegesSQL) + if err != nil { + return "", "", fmt.Errorf("failed to set default privileges: %w", err) + } + + // Step 7: Verify user creation before committing + var verifyUsername string + err = tx.QueryRow(ctx, fmt.Sprintf(`SELECT rolname FROM pg_roles WHERE rolname = '%s'`, username)). + Scan(&verifyUsername) + if err != nil { + return "", "", fmt.Errorf("failed to verify user creation: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return "", "", fmt.Errorf("failed to commit transaction: %w", err) + } + + success = true + logger.Info("Read-only user created successfully", "username", username) + return username, newPassword, nil + } + + return "", "", errors.New("failed to generate unique username after 3 attempts") +} + // testSingleDatabaseConnection tests connection to a specific database for pg_dump func testSingleDatabaseConnection( logger *slog.Logger, diff --git a/backend/internal/features/databases/databases/postgresql/readonly_user_test.go b/backend/internal/features/databases/databases/postgresql/readonly_user_test.go new file mode 100644 index 0000000..b55fee9 --- /dev/null +++ b/backend/internal/features/databases/databases/postgresql/readonly_user_test.go @@ -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") +} diff --git a/backend/internal/features/databases/dto.go b/backend/internal/features/databases/dto.go new file mode 100644 index 0000000..16c8062 --- /dev/null +++ b/backend/internal/features/databases/dto.go @@ -0,0 +1,10 @@ +package databases + +type CreateReadOnlyUserResponse struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type IsReadOnlyResponse struct { + IsReadOnly bool `json:"isReadOnly"` +} diff --git a/backend/internal/features/databases/service.go b/backend/internal/features/databases/service.go index 5ac99c3..4fe065b 100644 --- a/backend/internal/features/databases/service.go +++ b/backend/internal/features/databases/service.go @@ -1,6 +1,7 @@ package databases import ( + "context" "errors" "fmt" "log/slog" @@ -456,3 +457,148 @@ func (s *DatabaseService) OnBeforeWorkspaceDeletion(workspaceID uuid.UUID) error return nil } + +func (s *DatabaseService) IsUserReadOnly( + user *users_models.User, + database *Database, +) (bool, error) { + var usingDatabase *Database + + if database.ID != uuid.Nil { + existingDatabase, err := s.dbRepository.FindByID(database.ID) + if err != nil { + return false, err + } + + if existingDatabase.WorkspaceID == nil { + return false, errors.New("cannot check user for database without workspace") + } + + canAccess, _, err := s.workspaceService.CanUserAccessWorkspace( + *existingDatabase.WorkspaceID, + user, + ) + if err != nil { + return false, err + } + if !canAccess { + return false, errors.New("insufficient permissions to access this database") + } + + if database.WorkspaceID != nil && *existingDatabase.WorkspaceID != *database.WorkspaceID { + return false, errors.New("database does not belong to this workspace") + } + + existingDatabase.Update(database) + + if err := existingDatabase.Validate(); err != nil { + return false, err + } + + usingDatabase = existingDatabase + } else { + if database.WorkspaceID != nil { + canAccess, _, err := s.workspaceService.CanUserAccessWorkspace(*database.WorkspaceID, user) + if err != nil { + return false, err + } + if !canAccess { + return false, errors.New("insufficient permissions to access this workspace") + } + } + + usingDatabase = database + } + + if usingDatabase.Type != DatabaseTypePostgres { + return false, errors.New("read-only check only supported for PostgreSQL databases") + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + return usingDatabase.Postgresql.IsUserReadOnly( + ctx, + s.logger, + s.fieldEncryptor, + usingDatabase.ID, + ) +} + +func (s *DatabaseService) CreateReadOnlyUser( + user *users_models.User, + database *Database, +) (string, string, error) { + var usingDatabase *Database + + if database.ID != uuid.Nil { + existingDatabase, err := s.dbRepository.FindByID(database.ID) + if err != nil { + return "", "", err + } + + if existingDatabase.WorkspaceID == nil { + return "", "", errors.New("cannot create user for database without workspace") + } + + canManage, err := s.workspaceService.CanUserManageDBs(*existingDatabase.WorkspaceID, user) + if err != nil { + return "", "", err + } + if !canManage { + return "", "", errors.New("insufficient permissions to manage this database") + } + + if database.WorkspaceID != nil && *existingDatabase.WorkspaceID != *database.WorkspaceID { + return "", "", errors.New("database does not belong to this workspace") + } + + existingDatabase.Update(database) + + if err := existingDatabase.Validate(); err != nil { + return "", "", err + } + + usingDatabase = existingDatabase + } else { + if database.WorkspaceID != nil { + canManage, err := s.workspaceService.CanUserManageDBs(*database.WorkspaceID, user) + if err != nil { + return "", "", err + } + if !canManage { + return "", "", errors.New("insufficient permissions to manage this workspace") + } + } + + usingDatabase = database + } + + if usingDatabase.Type != DatabaseTypePostgres { + return "", "", errors.New("read-only user creation only supported for PostgreSQL") + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + username, password, err := usingDatabase.Postgresql.CreateReadOnlyUser( + ctx, s.logger, s.fieldEncryptor, usingDatabase.ID, + ) + if err != nil { + return "", "", err + } + + if usingDatabase.WorkspaceID != nil { + s.auditLogService.WriteAuditLog( + fmt.Sprintf( + "Read-only user created for database: %s (username: %s)", + usingDatabase.Name, + username, + ), + &user.ID, + usingDatabase.WorkspaceID, + ) + } + + return username, password, nil +} diff --git a/backend/internal/features/restores/usecases/postgresql/restore_backup_uc.go b/backend/internal/features/restores/usecases/postgresql/restore_backup_uc.go index 8a7b593..c18190a 100644 --- a/backend/internal/features/restores/usecases/postgresql/restore_backup_uc.go +++ b/backend/internal/features/restores/usecases/postgresql/restore_backup_uc.go @@ -78,7 +78,8 @@ 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( diff --git a/frontend/src/entity/databases/api/databaseApi.ts b/frontend/src/entity/databases/api/databaseApi.ts index ea07428..30bc7d8 100644 --- a/frontend/src/entity/databases/api/databaseApi.ts +++ b/frontend/src/entity/databases/api/databaseApi.ts @@ -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( + `${getApplicationServer()}/api/v1/databases/is-readonly`, + requestOptions, + ); + }, + + async createReadOnlyUser(database: Database) { + const requestOptions: RequestOptions = new RequestOptions(); + requestOptions.setBody(JSON.stringify(database)); + return apiHelper.fetchPostJson( + `${getApplicationServer()}/api/v1/databases/create-readonly-user`, + requestOptions, + ); + }, }; diff --git a/frontend/src/entity/databases/index.ts b/frontend/src/entity/databases/index.ts index 23f7eda..d9e6357 100644 --- a/frontend/src/entity/databases/index.ts +++ b/frontend/src/entity/databases/index.ts @@ -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'; diff --git a/frontend/src/entity/databases/model/CreateReadOnlyUserResponse.ts b/frontend/src/entity/databases/model/CreateReadOnlyUserResponse.ts new file mode 100644 index 0000000..0cce55b --- /dev/null +++ b/frontend/src/entity/databases/model/CreateReadOnlyUserResponse.ts @@ -0,0 +1,4 @@ +export interface CreateReadOnlyUserResponse { + username: string; + password: string; +} diff --git a/frontend/src/entity/databases/model/IsReadOnlyResponse.ts b/frontend/src/entity/databases/model/IsReadOnlyResponse.ts new file mode 100644 index 0000000..7e9ae99 --- /dev/null +++ b/frontend/src/entity/databases/model/IsReadOnlyResponse.ts @@ -0,0 +1,3 @@ +export interface IsReadOnlyResponse { + isReadOnly: boolean; +} diff --git a/frontend/src/features/databases/ui/CreateDatabaseComponent.tsx b/frontend/src/features/databases/ui/CreateDatabaseComponent.tsx index 2c3c9d0..3360970 100644 --- a/frontend/src/features/databases/ui/CreateDatabaseComponent.tsx +++ b/frontend/src/features/databases/ui/CreateDatabaseComponent.tsx @@ -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 ( + { + setDatabase({ ...database }); + }} + onGoBack={() => setStep('db-settings')} + onContinue={() => setStep('backup-config')} + /> + ); + } + if (step === 'backup-config') { return ( 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 => { + 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 ( +
+ + Checking read-only user... +
+ ); + } + + return ( +
+
+

Create a read-only user for Postgresus?

+ +

+ 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: +

+ +
    +
  • it prevents accidental data modifications during backup
  • +
  • it follows the principle of least privilege
  • +
  • it's a security best practice
  • +
+ +

+ Postgresus enforce enterprise-grade security ( + + read in details here + + ). However, it is not possible to be covered from all possible risks. +

+ +

+ Read-only user allows to avoid storing credentials with write access at all. Even + in the worst case of hacking, nobody will be able to corrupt your data. +

+
+ +
+ + + + + +
+ + setShowSkipConfirmation(false)} + footer={null} + width={450} + > +
+

Are you sure you want to skip creating a read-only user?

+ +

+ 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. +

+ +

+ 100% protection is never possible. It'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. +

+
+ +
+ + + +
+
+
+ ); +}; diff --git a/frontend/src/features/databases/ui/edit/EditDatabaseHealthcheckConfigComponent.tsx b/frontend/src/features/databases/ui/edit/EditDatabaseHealthcheckConfigComponent.tsx deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/src/features/restores/ui/RestoresComponent.tsx b/frontend/src/features/restores/ui/RestoresComponent.tsx index 7cc75c7..1f182ab 100644 --- a/frontend/src/features/restores/ui/RestoresComponent.tsx +++ b/frontend/src/features/restores/ui/RestoresComponent.tsx @@ -1,6 +1,6 @@ -import { ExclamationCircleOutlined, SyncOutlined } from '@ant-design/icons'; +import { CopyOutlined, ExclamationCircleOutlined, SyncOutlined } from '@ant-design/icons'; import { CheckCircleOutlined } from '@ant-design/icons'; -import { Button, Modal, Spin, Tooltip } from 'antd'; +import { App, Button, Modal, Spin, Tooltip } from 'antd'; import dayjs from 'dayjs'; import { useEffect, useRef, useState } from 'react'; @@ -16,11 +16,14 @@ interface Props { } export const RestoresComponent = ({ database, backup }: Props) => { + const { message } = App.useApp(); + const [editingDatabase, setEditingDatabase] = useState({ ...database, postgresql: database.postgresql ? ({ ...database.postgresql, + username: undefined, host: undefined, port: undefined, password: undefined, @@ -231,9 +234,21 @@ export const RestoresComponent = ({ database, backup }: Props) => { title="Restore error details" open={!!showingRestoreError} onCancel={() => setShowingRestoreError(undefined)} - footer={null} + footer={ + + } > -
{showingRestoreError.failMessage}
+
+ {showingRestoreError.failMessage} +
)}