Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
# 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
4 changes: 2 additions & 2 deletions dev.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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"|*)
Expand Down
14 changes: 8 additions & 6 deletions docker-compose.dev.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
services:
postgres:
container_name: flowbi-postgres
container_name: pgweb-postgres
image: postgres:15
ports:
- "5433:5432"
Expand All @@ -19,7 +19,7 @@ services:
- pgweb

pgweb:
container_name: pgweb
container_name: pgweb-dev
build: .
ports:
- "8081:8081"
Expand All @@ -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",
Expand All @@ -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
24 changes: 24 additions & 0 deletions pkg/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
neturl "net/url"
"os"
"strings"
"time"

Expand Down Expand Up @@ -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)
Expand Down
27 changes: 16 additions & 11 deletions pkg/api/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"mime"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
Expand Down Expand Up @@ -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
}
Expand Down
69 changes: 44 additions & 25 deletions pkg/api/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"testing"

"github.com/gin-gonic/gin"
Expand Down Expand Up @@ -98,61 +99,79 @@ 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}

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")
}
12 changes: 12 additions & 0 deletions pkg/api/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package api

import (
"log"
"os"
"strings"

"github.com/gin-gonic/gin"
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions pkg/api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
19 changes: 19 additions & 0 deletions pkg/statements/sql.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package statements

import (
_ "embed"
"log"
"os"
"path/filepath"
)

var (
Expand All @@ -24,6 +27,8 @@ var (
TableIndexes string

//go:embed sql/table_constraints.sql
tableConstraintsEmbedded string

TableConstraints string

//go:embed sql/table_info.sql
Expand Down Expand Up @@ -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
}
2 changes: 1 addition & 1 deletion pkg/statements/sql/table_constraints.sql
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,4 @@ WHERE
n.nspname = $1
AND relname = $2
ORDER BY
contype DESC
contype DESC
Loading
Loading