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
181 changes: 181 additions & 0 deletions cmd/ignore_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -951,6 +951,187 @@ func verifyDumpOutput(t *testing.T, output string) {
}
}

func TestIgnorePrivileges(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}

embeddedPG := testutil.SetupPostgres(t)
defer embeddedPG.Stop()
conn, host, port, dbname, user, password := testutil.ConnectToPostgres(t, embeddedPG)
defer conn.Close()

containerInfo := &struct {
Conn *sql.DB
Host string
Port int
DBName string
User string
Password string
}{
Conn: conn,
Host: host,
Port: port,
DBName: dbname,
User: user,
Password: password,
}

// Create schema with roles and privileges
setupSQL := `
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL
);

CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id),
total_amount DECIMAL(10,2) NOT NULL
);

DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'app_reader') THEN
CREATE ROLE app_reader;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'deploy_bot') THEN
CREATE ROLE deploy_bot;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'admin_role') THEN
CREATE ROLE admin_role;
END IF;
END $$;

-- Privileges to keep
GRANT SELECT ON users TO app_reader;
GRANT SELECT ON orders TO app_reader;

-- Privileges to ignore (deploy_bot)
GRANT ALL ON users TO deploy_bot;
GRANT ALL ON orders TO deploy_bot;

-- Privileges to ignore (admin_role)
GRANT SELECT, INSERT, UPDATE, DELETE ON users TO admin_role;

-- Default privileges to keep
ALTER DEFAULT PRIVILEGES GRANT SELECT ON TABLES TO app_reader;

-- Default privileges to ignore (deploy_bot)
ALTER DEFAULT PRIVILEGES GRANT ALL ON TABLES TO deploy_bot;
`
_, err := conn.Exec(setupSQL)
if err != nil {
t.Fatalf("Failed to create test schema: %v", err)
}

originalWd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get current working directory: %v", err)
}
defer func() {
if err := os.Chdir(originalWd); err != nil {
t.Fatalf("Failed to restore working directory: %v", err)
}
}()

tmpDir := t.TempDir()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("Failed to change to temp directory: %v", err)
}

// Create .pgschemaignore with privileges section
ignoreContent := `[privileges]
patterns = ["deploy_bot", "admin_*"]

[default_privileges]
patterns = ["deploy_bot"]
`
err = os.WriteFile(".pgschemaignore", []byte(ignoreContent), 0644)
if err != nil {
t.Fatalf("Failed to create .pgschemaignore: %v", err)
}

t.Run("dump", func(t *testing.T) {
output := executeIgnoreDumpCommand(t, containerInfo)

// Privileges for app_reader should be present
if !strings.Contains(output, "app_reader") {
t.Error("Dump should include privileges for app_reader")
}

// Privileges for deploy_bot should be ignored
if strings.Contains(output, "deploy_bot") {
t.Error("Dump should not include privileges for deploy_bot (ignored)")
}

// Privileges for admin_role should be ignored (matches admin_*)
if strings.Contains(output, "admin_role") {
t.Error("Dump should not include privileges for admin_role (ignored by admin_* pattern)")
}
})

t.Run("plan", func(t *testing.T) {
// Create schema file that adds new privileges
schemaSQL := `
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL
);

CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id),
total_amount DECIMAL(10,2) NOT NULL
);

DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'app_reader') THEN
CREATE ROLE app_reader;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'deploy_bot') THEN
CREATE ROLE deploy_bot;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'admin_role') THEN
CREATE ROLE admin_role;
END IF;
END $$;

-- Keep these privileges
GRANT SELECT ON users TO app_reader;
GRANT SELECT ON orders TO app_reader;

-- These should be ignored in plan
GRANT ALL ON users TO deploy_bot;
GRANT ALL ON orders TO deploy_bot;
GRANT SELECT, INSERT, UPDATE, DELETE ON users TO admin_role;

-- Default privileges
ALTER DEFAULT PRIVILEGES GRANT SELECT ON TABLES TO app_reader;
ALTER DEFAULT PRIVILEGES GRANT ALL ON TABLES TO deploy_bot;
`
schemaFile := "schema_privs.sql"
err := os.WriteFile(schemaFile, []byte(schemaSQL), 0644)
if err != nil {
t.Fatalf("Failed to create schema file: %v", err)
}
defer os.Remove(schemaFile)

output := executeIgnorePlanCommand(t, containerInfo, schemaFile)

// Plan should not contain any changes for ignored roles
if strings.Contains(output, "deploy_bot") {
t.Error("Plan should not include changes for deploy_bot (ignored)")
}
if strings.Contains(output, "admin_role") {
t.Error("Plan should not include changes for admin_role (ignored)")
}
})
}

// verifyPlanOutput checks that plan output excludes ignored objects
func verifyPlanOutput(t *testing.T, output string) {
// Changes that should appear in plan (regular objects)
Expand Down
40 changes: 28 additions & 12 deletions cmd/util/ignoreloader.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,14 @@ func LoadIgnoreFileFromPath(filePath string) (*ir.IgnoreConfig, error) {
// TomlConfig represents the TOML structure of the .pgschemaignore file
// This is used for parsing more complex configurations if needed in the future
type TomlConfig struct {
Tables TableIgnoreConfig `toml:"tables,omitempty"`
Views ViewIgnoreConfig `toml:"views,omitempty"`
Functions FunctionIgnoreConfig `toml:"functions,omitempty"`
Procedures ProcedureIgnoreConfig `toml:"procedures,omitempty"`
Types TypeIgnoreConfig `toml:"types,omitempty"`
Sequences SequenceIgnoreConfig `toml:"sequences,omitempty"`
Tables TableIgnoreConfig `toml:"tables,omitempty"`
Views ViewIgnoreConfig `toml:"views,omitempty"`
Functions FunctionIgnoreConfig `toml:"functions,omitempty"`
Procedures ProcedureIgnoreConfig `toml:"procedures,omitempty"`
Types TypeIgnoreConfig `toml:"types,omitempty"`
Sequences SequenceIgnoreConfig `toml:"sequences,omitempty"`
Privileges PrivilegeIgnoreConfig `toml:"privileges,omitempty"`
DefaultPrivileges DefaultPrivilegeIgnoreConfig `toml:"default_privileges,omitempty"`
}

// TableIgnoreConfig represents table-specific ignore configuration
Expand Down Expand Up @@ -66,6 +68,18 @@ type SequenceIgnoreConfig struct {
Patterns []string `toml:"patterns,omitempty"`
}

// PrivilegeIgnoreConfig represents privilege-specific ignore configuration
// Patterns match on grantee role names
type PrivilegeIgnoreConfig struct {
Patterns []string `toml:"patterns,omitempty"`
}

// DefaultPrivilegeIgnoreConfig represents default privilege-specific ignore configuration
// Patterns match on grantee role names
type DefaultPrivilegeIgnoreConfig struct {
Patterns []string `toml:"patterns,omitempty"`
}

// LoadIgnoreFileWithStructure loads the .pgschemaignore file using the structured TOML format
// and converts it to the simple IgnoreConfig structure
func LoadIgnoreFileWithStructure() (*ir.IgnoreConfig, error) {
Expand All @@ -91,12 +105,14 @@ func LoadIgnoreFileWithStructureFromPath(filePath string) (*ir.IgnoreConfig, err

// Convert to simple IgnoreConfig structure
config := &ir.IgnoreConfig{
Tables: tomlConfig.Tables.Patterns,
Views: tomlConfig.Views.Patterns,
Functions: tomlConfig.Functions.Patterns,
Procedures: tomlConfig.Procedures.Patterns,
Types: tomlConfig.Types.Patterns,
Sequences: tomlConfig.Sequences.Patterns,
Tables: tomlConfig.Tables.Patterns,
Views: tomlConfig.Views.Patterns,
Functions: tomlConfig.Functions.Patterns,
Procedures: tomlConfig.Procedures.Patterns,
Types: tomlConfig.Types.Patterns,
Sequences: tomlConfig.Sequences.Patterns,
Privileges: tomlConfig.Privileges.Patterns,
DefaultPrivileges: tomlConfig.DefaultPrivileges.Patterns,
}

return config, nil
Expand Down
63 changes: 63 additions & 0 deletions cmd/util/ignoreloader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,69 @@ patterns = ["fn_test_*"]
}
}

func TestLoadIgnoreFile_PrivilegeSections(t *testing.T) {
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test.pgschemaignore")

tomlContent := `[privileges]
patterns = ["deploy_bot", "admin_*", "!admin_super"]

[default_privileges]
patterns = ["deploy_bot"]
`

err := os.WriteFile(testFile, []byte(tomlContent), 0644)
if err != nil {
t.Fatalf("Failed to write test file: %v", err)
}

config, err := LoadIgnoreFileFromPath(testFile)
if err != nil {
t.Fatalf("LoadIgnoreFileFromPath() error = %v", err)
}
if config == nil {
t.Fatal("LoadIgnoreFileFromPath() returned nil config")
}

// Test privileges section
expectedPrivileges := []string{"deploy_bot", "admin_*", "!admin_super"}
if len(config.Privileges) != len(expectedPrivileges) {
t.Errorf("Expected %d privilege patterns, got %d", len(expectedPrivileges), len(config.Privileges))
}
for i, expected := range expectedPrivileges {
if i < len(config.Privileges) && config.Privileges[i] != expected {
t.Errorf("Expected privilege pattern %q at index %d, got %q", expected, i, config.Privileges[i])
}
}

// Test default_privileges section
if len(config.DefaultPrivileges) != 1 || config.DefaultPrivileges[0] != "deploy_bot" {
t.Errorf("Expected default_privileges patterns [\"deploy_bot\"], got %v", config.DefaultPrivileges)
}

// Test ShouldIgnorePrivilege
if !config.ShouldIgnorePrivilege("deploy_bot") {
t.Error("deploy_bot should be ignored")
}
if !config.ShouldIgnorePrivilege("admin_role") {
t.Error("admin_role should be ignored (matches admin_*)")
}
if config.ShouldIgnorePrivilege("admin_super") {
t.Error("admin_super should NOT be ignored (negation pattern)")
}
if config.ShouldIgnorePrivilege("app_reader") {
t.Error("app_reader should NOT be ignored")
}

// Test ShouldIgnoreDefaultPrivilege
if !config.ShouldIgnoreDefaultPrivilege("deploy_bot") {
t.Error("deploy_bot default privilege should be ignored")
}
if config.ShouldIgnoreDefaultPrivilege("app_reader") {
t.Error("app_reader default privilege should NOT be ignored")
}
}

func TestLoadIgnoreFile_InvalidTOML(t *testing.T) {
// Create a temporary invalid TOML file
tempDir := t.TempDir()
Expand Down
25 changes: 25 additions & 0 deletions docs/cli/ignore.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ The `.pgschemaignore` file allows you to exclude database objects from pgschema
2. **Temporary Objects** - Exclude temp tables, debug views, and development-only objects
3. **Legacy Objects** - Ignore deprecated objects while maintaining new schema management
4. **Environment-Specific Objects** - Skip objects that exist only in certain environments
5. **Role-Specific Privileges** - Ignore grants to roles that don't exist in the plan database

## File Format

Expand Down Expand Up @@ -39,6 +40,12 @@ patterns = ["type_test_*"]

[sequences]
patterns = ["seq_temp_*", "seq_debug_*"]

[privileges]
patterns = ["deploy_bot", "admin_*"]

[default_privileges]
patterns = ["deploy_bot"]
```

## Pattern Syntax
Expand Down Expand Up @@ -79,6 +86,24 @@ patterns = [

This will ignore `test_data`, `test_results` but keep `test_core_config`, `test_core_settings`.

## Privileges

The `[privileges]` and `[default_privileges]` sections filter GRANT statements by **grantee role name**. This is useful when running `pgschema plan` with roles that don't exist in the plan database, or managing migrations across environments with different role configurations.

```toml
[privileges]
patterns = [
"deploy_bot", # Ignore all grants to deploy_bot
"admin_*", # Ignore grants to any admin_* role
"!admin_super" # But keep grants to admin_super
]

[default_privileges]
patterns = ["deploy_bot"] # Ignore ALTER DEFAULT PRIVILEGES for deploy_bot
```

The `[privileges]` section filters explicit grants (`GRANT ... TO role`), including column-level privileges. The `[default_privileges]` section filters `ALTER DEFAULT PRIVILEGES` statements.

## Triggers on Ignored Tables

Triggers can be defined on ignored tables. The table structure is not managed, but the trigger itself is.
Expand Down
Loading
Loading