diff --git a/.env.example b/.env.example index dfd1a81ef..82c5e9652 100644 --- a/.env.example +++ b/.env.example @@ -4,5 +4,9 @@ # For local development with included PostgreSQL container PGWEB_DATABASE_URL=postgres://pgweb_dev:pgweb_dev_password@postgres:5432/pgweb_test?sslmode=disable -# For connecting to your production database (don't commit the real URL!) -# PGWEB_DATABASE_URL=postgres://your_user:your_password@your_host:5432/your_database \ No newline at end of file +# Custom Parameter Patterns (Issue #39) +# Configure custom variables - Enables variable detection from url parameter for iframe embedding +PGWEB_CUSTOM_PARAMS="Client,Instance,ClientName,InstanceName,AccountId,AccountPerspective,AccountDbUser,AccountName,AccountEmail,FolderName" + +# Test role for RLS (Issue #15) - used for testing multi-tenancy +PGWEB_TEST_ROLE=test_tenant_role \ No newline at end of file diff --git a/dev.sh b/dev.sh index 385ef991a..b4a6f7a30 100755 --- a/dev.sh +++ b/dev.sh @@ -93,7 +93,7 @@ case "$ACTION" in # Test parameter substitution echo_info "Test URL with parameters:" - echo_info "http://localhost:8081/?gsr_client=test-client&gsr_inst=test-instance" + echo_info "http://localhost:8081/?Client=client&Instance=instance&ClientName=clientname&InstanceName=instance-name&AccountId=account-id&AccountPerspective=account-perspective&AccountDbUser=account-db-user&AccountName=account-name&AccountEmail=account-email&FolderName=folder-name&InvalidParameter=shouldnotshow" ;; "stop"|"down") @@ -131,7 +131,7 @@ case "$ACTION" in echo_info "Testing parameter substitution..." sleep 2 echo_info "Opening pgweb with test parameters..." - open "http://localhost:8081/?gsr_client=test-client&gsr_inst=test-instance" 2>/dev/null || echo_warning "Could not open browser automatically" + open "http://localhost:8081/?Client=client&Instance=instance&ClientName=clientname&InstanceName=instance-name&AccountId=account-id&AccountPerspective=account-perspective&AccountDbUser=account-db-user&AccountName=account-name&AccountEmail=account-email&FolderName=folder-name&InvalidParameter=shouldnotshow" 2>/dev/null || echo_warning "Could not open browser automatically" ;; "help"|*) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 864640391..52d29c2d8 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,6 +1,6 @@ services: postgres: - container_name: flowbi-postgres + container_name: pgweb-postgres image: postgres:15 ports: - "5433:5432" @@ -19,7 +19,7 @@ services: - pgweb pgweb: - container_name: pgweb + container_name: pgweb-dev build: . ports: - "8081:8081" @@ -29,12 +29,14 @@ services: environment: # Default to local postgres for development # Override PGWEB_DATABASE_URL for production with URL-encoded password - PGWEB_DATABASE_URL: ${PGWEB_DATABASE_URL:-postgres://pgweb_dev:pgweb_dev_password@postgres:5432/pgweb_test} + PGWEB_DATABASE_URL: ${PGWEB_DATABASE_URL:-postgres://pgweb_dev:pgweb_dev_password@postgres:5432/pgweb_test?sslmode=disable} + PGWEB_TEST_ROLE: ${PGWEB_TEST_ROLE} + PGWEB_CUSTOM_PARAMS: ${PGWEB_CUSTOM_PARAMS} command: [ "./pgweb", "--url", - "${PGWEB_DATABASE_URL:-postgres://pgweb_dev:pgweb_dev_password@postgres:5432/pgweb_test}", + "${PGWEB_DATABASE_URL:-postgres://pgweb_dev:pgweb_dev_password@postgres:5432/pgweb_test?sslmode=disable}", "--bind", "0.0.0.0", "--listen", @@ -46,8 +48,8 @@ services: volumes: postgres_data: - name: flowbi_pgweb_postgres_data + name: pgweb_postgres_data networks: pgweb: - name: flowbi_pgweb + name: pgweb_dev diff --git a/pkg/api/api.go b/pkg/api/api.go index 20b1e69c8..ee8d2c25e 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" neturl "net/url" + "os" "strings" "time" @@ -622,6 +623,29 @@ func GetInfo(c *gin.Context) { }) } +// GetConfig returns client configuration including custom parameter patterns +func GetConfig(c *gin.Context) { + // Get custom parameter patterns from environment variable + customParams := os.Getenv("PGWEB_CUSTOM_PARAMS") + + config := gin.H{ + "parameter_patterns": gin.H{ + "custom": []string{}, + }, + } + + // Add custom patterns if configured (no defaults) + if customParams != "" { + customPatternsList := strings.Split(customParams, ",") + for i, pattern := range customPatternsList { + customPatternsList[i] = strings.TrimSpace(pattern) + } + config["parameter_patterns"].(gin.H)["custom"] = customPatternsList + } + + successResponse(c, config) +} + // DataExport performs database table export func DataExport(c *gin.Context) { db := DB(c) diff --git a/pkg/api/helpers.go b/pkg/api/helpers.go index dff81e083..8f4d3c319 100644 --- a/pkg/api/helpers.go +++ b/pkg/api/helpers.go @@ -4,6 +4,7 @@ import ( "fmt" "mime" "net/http" + "os" "path/filepath" "regexp" "strconv" @@ -190,25 +191,29 @@ func badRequest(c *gin.Context, err interface{}) { } // extractURLParams extracts query parameters that should be used for SQL parameter substitution -// Returns a map of parameters that start with common SQL parameter prefixes +// Returns a map of parameters that match configured patterns from PGWEB_CUSTOM_PARAMS func extractURLParams(c *gin.Context) map[string]string { params := make(map[string]string) - // Define common SQL parameter patterns - paramPatterns := []*regexp.Regexp{ - regexp.MustCompile(`^gsr_\w+$`), // gsr_client, gsr_inst, etc. - regexp.MustCompile(`^tenant_\w+$`), // tenant_id, tenant_name, etc. - regexp.MustCompile(`^user_\w+$`), // user_id, user_role, etc. - regexp.MustCompile(`^client_\w+$`), // client_id, client_name, etc. - regexp.MustCompile(`^app_\w+$`), // app_id, app_name, etc. + // Get custom parameter patterns from environment variable + customParams := os.Getenv("PGWEB_CUSTOM_PARAMS") + if customParams == "" { + // No patterns configured - parameter feature disabled + return params + } + + // Parse configured patterns (exact matches only) + configuredPatterns := strings.Split(customParams, ",") + for i, pattern := range configuredPatterns { + configuredPatterns[i] = strings.TrimSpace(pattern) } // Extract all query parameters for key, values := range c.Request.URL.Query() { if len(values) > 0 { - // Check if this parameter matches our SQL parameter patterns - for _, pattern := range paramPatterns { - if pattern.MatchString(key) { + // Check if this parameter matches configured patterns (exact match) + for _, pattern := range configuredPatterns { + if key == pattern { params[key] = values[0] // Use the first value break } diff --git a/pkg/api/helpers_test.go b/pkg/api/helpers_test.go index f64b53ff5..4b97fd6eb 100644 --- a/pkg/api/helpers_test.go +++ b/pkg/api/helpers_test.go @@ -5,6 +5,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "os" "testing" "github.com/gin-gonic/gin" @@ -98,13 +99,19 @@ func TestExtractURLParamsEmpty(t *testing.T) { assert.Empty(t, params) } -func TestExtractURLParamsGSRPattern(t *testing.T) { +func TestExtractURLParamsWithConfiguredPatterns(t *testing.T) { gin.SetMode(gin.TestMode) + // Set environment variable for test + originalEnv := os.Getenv("PGWEB_CUSTOM_PARAMS") + os.Setenv("PGWEB_CUSTOM_PARAMS", "Client,Instance,AccountId") + defer os.Setenv("PGWEB_CUSTOM_PARAMS", originalEnv) + values := url.Values{} - values.Set("gsr_client", "test-client") - values.Set("gsr_inst", "test-instance") - values.Set("gsr_environment", "production") + values.Set("Client", "test-client") + values.Set("Instance", "test-instance") + values.Set("AccountId", "123") + values.Set("ignored_param", "should-be-ignored") req, _ := http.NewRequest("GET", "/api/query?"+values.Encode(), nil) c := &gin.Context{Request: req} @@ -112,47 +119,59 @@ func TestExtractURLParamsGSRPattern(t *testing.T) { params := extractURLParams(c) assert.Len(t, params, 3) - assert.Equal(t, "test-client", params["gsr_client"]) - assert.Equal(t, "test-instance", params["gsr_inst"]) - assert.Equal(t, "production", params["gsr_environment"]) + assert.Equal(t, "test-client", params["Client"]) + assert.Equal(t, "test-instance", params["Instance"]) + assert.Equal(t, "123", params["AccountId"]) + assert.NotContains(t, params, "ignored_param") } -func TestExtractURLParamsMixedPatterns(t *testing.T) { +func TestExtractURLParamsNoConfiguration(t *testing.T) { gin.SetMode(gin.TestMode) + // Clear environment variable for test + originalEnv := os.Getenv("PGWEB_CUSTOM_PARAMS") + os.Setenv("PGWEB_CUSTOM_PARAMS", "") + defer os.Setenv("PGWEB_CUSTOM_PARAMS", originalEnv) + values := url.Values{} - values.Set("gsr_client", "test-client") - values.Set("tenant_id", "123") - values.Set("user_role", "admin") - values.Set("ignored_param", "should-not-appear") - values.Set("primaryColor", "#007bff") // UI parameter, should be ignored + values.Set("Client", "test-client") + values.Set("Instance", "test-instance") + values.Set("any_param", "any-value") req, _ := http.NewRequest("GET", "/api/query?"+values.Encode(), nil) c := &gin.Context{Request: req} params := extractURLParams(c) - assert.Len(t, params, 3) - assert.Equal(t, "test-client", params["gsr_client"]) - assert.Equal(t, "123", params["tenant_id"]) - assert.Equal(t, "admin", params["user_role"]) - assert.NotContains(t, params, "ignored_param") - assert.NotContains(t, params, "primaryColor") + // Should be empty when no patterns are configured + assert.Empty(t, params) } -func TestExtractURLParamsInvalidPatterns(t *testing.T) { +func TestExtractURLParamsExactMatchOnly(t *testing.T) { gin.SetMode(gin.TestMode) + // Set environment variable for test + originalEnv := os.Getenv("PGWEB_CUSTOM_PARAMS") + os.Setenv("PGWEB_CUSTOM_PARAMS", "Client,Instance") + defer os.Setenv("PGWEB_CUSTOM_PARAMS", originalEnv) + values := url.Values{} - values.Set("gsr", "should-not-match") // No underscore - values.Set("gsr_", "should-not-match") // No word after underscore - values.Set("_client", "should-not-match") // Starts with underscore - values.Set("tenant", "should-not-match") // No underscore + values.Set("Client", "should-match") // Exact match + values.Set("client", "should-not-match") // Case sensitive - doesn't match + values.Set("ClientId", "should-not-match") // Partial match - doesn't match + values.Set("Instance", "should-match") // Exact match + values.Set("tenant", "should-not-match") // No underscore req, _ := http.NewRequest("GET", "/api/query?"+values.Encode(), nil) c := &gin.Context{Request: req} params := extractURLParams(c) - assert.Empty(t, params) + // Should only match exact parameter names + assert.Len(t, params, 2) + assert.Equal(t, "should-match", params["Client"]) + assert.Equal(t, "should-match", params["Instance"]) + assert.NotContains(t, params, "client") + assert.NotContains(t, params, "ClientId") + assert.NotContains(t, params, "tenant") } diff --git a/pkg/api/middleware.go b/pkg/api/middleware.go index a7fb25a69..9d3d0e598 100644 --- a/pkg/api/middleware.go +++ b/pkg/api/middleware.go @@ -2,6 +2,7 @@ package api import ( "log" + "os" "strings" "github.com/gin-gonic/gin" @@ -64,6 +65,17 @@ func roleInjectionMiddleware() gin.HandlerFunc { // Extract X-Database-Role header role := c.GetHeader("X-Database-Role") + // If no header role, check for test role environment variable (for development/testing) + if role == "" { + testRole := os.Getenv("PGWEB_TEST_ROLE") + if testRole != "" { + role = testRole + if command.Opts.Debug { + log.Printf("Using test role from environment: %s", role) + } + } + } + if role != "" { // Get the current database client client := DB(c) diff --git a/pkg/api/routes.go b/pkg/api/routes.go index d8e21c2b8..879524657 100644 --- a/pkg/api/routes.go +++ b/pkg/api/routes.go @@ -32,6 +32,7 @@ func SetupRoutes(router *gin.Engine) { } api.GET("/info", GetInfo) + api.GET("/config", GetConfig) api.POST("/connect", Connect) api.POST("/disconnect", Disconnect) api.POST("/switchdb", SwitchDb) diff --git a/pkg/statements/sql.go b/pkg/statements/sql.go index a12e1c0f3..46f8cc851 100644 --- a/pkg/statements/sql.go +++ b/pkg/statements/sql.go @@ -2,6 +2,9 @@ package statements import ( _ "embed" + "log" + "os" + "path/filepath" ) var ( @@ -24,6 +27,8 @@ var ( TableIndexes string //go:embed sql/table_constraints.sql + tableConstraintsEmbedded string + TableConstraints string //go:embed sql/table_info.sql @@ -61,3 +66,17 @@ var ( "9.6": "SELECT datname, query, state, wait_event, wait_event_type, query_start, state_change, pid, datid, application_name, client_addr FROM pg_stat_activity WHERE datname = current_database() and usename = current_user", } ) + +func init() { + TableConstraints = loadTableConstraintsSQL() +} + +func loadTableConstraintsSQL() string { + externalPath := filepath.Join("/tmp/queries", "table_constraints.sql") + if data, err := os.ReadFile(externalPath); err == nil { + log.Printf("Using external table_constraints.sql from: %s", externalPath) + return string(data) + } + + return tableConstraintsEmbedded +} diff --git a/pkg/statements/sql/table_constraints.sql b/pkg/statements/sql/table_constraints.sql index 39931af3e..d9059e8f6 100644 --- a/pkg/statements/sql/table_constraints.sql +++ b/pkg/statements/sql/table_constraints.sql @@ -11,4 +11,4 @@ WHERE n.nspname = $1 AND relname = $2 ORDER BY - contype DESC + contype DESC \ No newline at end of file diff --git a/static/js/app.js b/static/js/app.js index 17a07fbf0..8e951bf39 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -9,6 +9,7 @@ var autocompleteObjects = []; var inputResizing = false; var inputResizeOffset = null; var globalSqlParams = {}; +var parameterPatterns = []; // Will be loaded dynamically from server config var filterOptions = { "equal": "= 'DATA'", @@ -229,6 +230,7 @@ function apiCall(method, path, params, cb) { } function getInfo(cb) { apiCall("get", "/info", {}, cb); } +function getConfig(cb) { apiCall("get", "/config", {}, cb); } function getConnection(cb) { apiCall("get", "/connection", {}, cb); } function getServerSettings(cb) { apiCall("get", "/server_settings", {}, cb); } function getSchemas(cb) { apiCall("get", "/schemas", {}, cb); } @@ -251,20 +253,51 @@ function encodeQuery(query) { return Base64.encode(query).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "."); } +// Load parameter patterns configuration from server +function loadParameterPatterns(callback) { + getConfig(function(config) { + if (config.error) { + console.warn("Could not load parameter patterns config:", config.error); + // No fallback - parameter feature disabled if config fails + parameterPatterns = []; + if (callback) callback(); + return; + } + + // Build exact match patterns from server configuration (simple names only) + parameterPatterns = []; + + // Add custom patterns (only exact matches) + if (config.parameter_patterns && config.parameter_patterns.custom) { + config.parameter_patterns.custom.forEach(function(pattern) { + // Simple name exact match only + parameterPatterns.push(new RegExp("^" + pattern + "$")); + }); + } + + console.log("Loaded parameter patterns:", parameterPatterns.map(function(p) { return p.toString(); })); + if (callback) callback(); + }); +} + // Extract SQL parameters from URL and store globally function extractSqlParameters() { var urlParams = new URLSearchParams(window.location.search); var sqlParams = {}; - // Define common SQL parameter patterns (matching backend patterns) - var paramPatterns = [/^gsr_\w+$/, /^tenant_\w+$/, /^user_\w+$/, /^client_\w+$/, /^app_\w+$/]; + // Only use configured patterns - no fallback + if (parameterPatterns.length === 0) { + // Parameter feature disabled if no patterns configured + globalSqlParams = sqlParams; + return sqlParams; + } for (var pair of urlParams.entries()) { var key = pair[0]; var value = pair[1]; - // Check if this parameter matches SQL parameter patterns - for (var pattern of paramPatterns) { + // Check if this parameter matches configured patterns + for (var pattern of parameterPatterns) { if (pattern.test(key)) { sqlParams[key] = value; break; @@ -2178,8 +2211,11 @@ $(document).ready(function() { window.history.pushState({}, document.title, window.location.pathname); } - // Display URL parameters for query substitution - displayURLParameters(); + // Load parameter patterns configuration first, then initialize + loadParameterPatterns(function() { + // Display URL parameters for query substitution after patterns are loaded + displayURLParameters(); + }); getInfo(function(resp) { if (resp.error) {