Skip to content

Commit c885408

Browse files
tianzhouclaude
andauthored
feat: add support for privileges in .pgschemaignore (#339) (#340)
* feat: add support for privileges and default_privileges in .pgschemaignore (#339) Add [privileges] and [default_privileges] sections to .pgschemaignore that filter grants by grantee role name patterns. This allows ignoring privilege statements for roles that don't exist in the plan database (e.g., production roles not available in embedded postgres). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address review feedback - check chdir error and add loader unit test - Check os.Chdir error in TestIgnorePrivileges (consistency with existing tests) - Add TestLoadIgnoreFile_PrivilegeSections unit test for TOML parsing of [privileges] and [default_privileges] sections including negation patterns Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add privileges and default_privileges sections to ignore docs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e4adb5e commit c885408

6 files changed

Lines changed: 336 additions & 18 deletions

File tree

cmd/ignore_integration_test.go

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -951,6 +951,187 @@ func verifyDumpOutput(t *testing.T, output string) {
951951
}
952952
}
953953

954+
func TestIgnorePrivileges(t *testing.T) {
955+
if testing.Short() {
956+
t.Skip("Skipping integration test in short mode")
957+
}
958+
959+
embeddedPG := testutil.SetupPostgres(t)
960+
defer embeddedPG.Stop()
961+
conn, host, port, dbname, user, password := testutil.ConnectToPostgres(t, embeddedPG)
962+
defer conn.Close()
963+
964+
containerInfo := &struct {
965+
Conn *sql.DB
966+
Host string
967+
Port int
968+
DBName string
969+
User string
970+
Password string
971+
}{
972+
Conn: conn,
973+
Host: host,
974+
Port: port,
975+
DBName: dbname,
976+
User: user,
977+
Password: password,
978+
}
979+
980+
// Create schema with roles and privileges
981+
setupSQL := `
982+
CREATE TABLE users (
983+
id SERIAL PRIMARY KEY,
984+
name TEXT NOT NULL,
985+
email TEXT NOT NULL
986+
);
987+
988+
CREATE TABLE orders (
989+
id SERIAL PRIMARY KEY,
990+
user_id INTEGER REFERENCES users(id),
991+
total_amount DECIMAL(10,2) NOT NULL
992+
);
993+
994+
DO $$
995+
BEGIN
996+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'app_reader') THEN
997+
CREATE ROLE app_reader;
998+
END IF;
999+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'deploy_bot') THEN
1000+
CREATE ROLE deploy_bot;
1001+
END IF;
1002+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'admin_role') THEN
1003+
CREATE ROLE admin_role;
1004+
END IF;
1005+
END $$;
1006+
1007+
-- Privileges to keep
1008+
GRANT SELECT ON users TO app_reader;
1009+
GRANT SELECT ON orders TO app_reader;
1010+
1011+
-- Privileges to ignore (deploy_bot)
1012+
GRANT ALL ON users TO deploy_bot;
1013+
GRANT ALL ON orders TO deploy_bot;
1014+
1015+
-- Privileges to ignore (admin_role)
1016+
GRANT SELECT, INSERT, UPDATE, DELETE ON users TO admin_role;
1017+
1018+
-- Default privileges to keep
1019+
ALTER DEFAULT PRIVILEGES GRANT SELECT ON TABLES TO app_reader;
1020+
1021+
-- Default privileges to ignore (deploy_bot)
1022+
ALTER DEFAULT PRIVILEGES GRANT ALL ON TABLES TO deploy_bot;
1023+
`
1024+
_, err := conn.Exec(setupSQL)
1025+
if err != nil {
1026+
t.Fatalf("Failed to create test schema: %v", err)
1027+
}
1028+
1029+
originalWd, err := os.Getwd()
1030+
if err != nil {
1031+
t.Fatalf("Failed to get current working directory: %v", err)
1032+
}
1033+
defer func() {
1034+
if err := os.Chdir(originalWd); err != nil {
1035+
t.Fatalf("Failed to restore working directory: %v", err)
1036+
}
1037+
}()
1038+
1039+
tmpDir := t.TempDir()
1040+
if err := os.Chdir(tmpDir); err != nil {
1041+
t.Fatalf("Failed to change to temp directory: %v", err)
1042+
}
1043+
1044+
// Create .pgschemaignore with privileges section
1045+
ignoreContent := `[privileges]
1046+
patterns = ["deploy_bot", "admin_*"]
1047+
1048+
[default_privileges]
1049+
patterns = ["deploy_bot"]
1050+
`
1051+
err = os.WriteFile(".pgschemaignore", []byte(ignoreContent), 0644)
1052+
if err != nil {
1053+
t.Fatalf("Failed to create .pgschemaignore: %v", err)
1054+
}
1055+
1056+
t.Run("dump", func(t *testing.T) {
1057+
output := executeIgnoreDumpCommand(t, containerInfo)
1058+
1059+
// Privileges for app_reader should be present
1060+
if !strings.Contains(output, "app_reader") {
1061+
t.Error("Dump should include privileges for app_reader")
1062+
}
1063+
1064+
// Privileges for deploy_bot should be ignored
1065+
if strings.Contains(output, "deploy_bot") {
1066+
t.Error("Dump should not include privileges for deploy_bot (ignored)")
1067+
}
1068+
1069+
// Privileges for admin_role should be ignored (matches admin_*)
1070+
if strings.Contains(output, "admin_role") {
1071+
t.Error("Dump should not include privileges for admin_role (ignored by admin_* pattern)")
1072+
}
1073+
})
1074+
1075+
t.Run("plan", func(t *testing.T) {
1076+
// Create schema file that adds new privileges
1077+
schemaSQL := `
1078+
CREATE TABLE users (
1079+
id SERIAL PRIMARY KEY,
1080+
name TEXT NOT NULL,
1081+
email TEXT NOT NULL
1082+
);
1083+
1084+
CREATE TABLE orders (
1085+
id SERIAL PRIMARY KEY,
1086+
user_id INTEGER REFERENCES users(id),
1087+
total_amount DECIMAL(10,2) NOT NULL
1088+
);
1089+
1090+
DO $$
1091+
BEGIN
1092+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'app_reader') THEN
1093+
CREATE ROLE app_reader;
1094+
END IF;
1095+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'deploy_bot') THEN
1096+
CREATE ROLE deploy_bot;
1097+
END IF;
1098+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'admin_role') THEN
1099+
CREATE ROLE admin_role;
1100+
END IF;
1101+
END $$;
1102+
1103+
-- Keep these privileges
1104+
GRANT SELECT ON users TO app_reader;
1105+
GRANT SELECT ON orders TO app_reader;
1106+
1107+
-- These should be ignored in plan
1108+
GRANT ALL ON users TO deploy_bot;
1109+
GRANT ALL ON orders TO deploy_bot;
1110+
GRANT SELECT, INSERT, UPDATE, DELETE ON users TO admin_role;
1111+
1112+
-- Default privileges
1113+
ALTER DEFAULT PRIVILEGES GRANT SELECT ON TABLES TO app_reader;
1114+
ALTER DEFAULT PRIVILEGES GRANT ALL ON TABLES TO deploy_bot;
1115+
`
1116+
schemaFile := "schema_privs.sql"
1117+
err := os.WriteFile(schemaFile, []byte(schemaSQL), 0644)
1118+
if err != nil {
1119+
t.Fatalf("Failed to create schema file: %v", err)
1120+
}
1121+
defer os.Remove(schemaFile)
1122+
1123+
output := executeIgnorePlanCommand(t, containerInfo, schemaFile)
1124+
1125+
// Plan should not contain any changes for ignored roles
1126+
if strings.Contains(output, "deploy_bot") {
1127+
t.Error("Plan should not include changes for deploy_bot (ignored)")
1128+
}
1129+
if strings.Contains(output, "admin_role") {
1130+
t.Error("Plan should not include changes for admin_role (ignored)")
1131+
}
1132+
})
1133+
}
1134+
9541135
// verifyPlanOutput checks that plan output excludes ignored objects
9551136
func verifyPlanOutput(t *testing.T, output string) {
9561137
// Changes that should appear in plan (regular objects)

cmd/util/ignoreloader.go

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,14 @@ func LoadIgnoreFileFromPath(filePath string) (*ir.IgnoreConfig, error) {
2828
// TomlConfig represents the TOML structure of the .pgschemaignore file
2929
// This is used for parsing more complex configurations if needed in the future
3030
type TomlConfig struct {
31-
Tables TableIgnoreConfig `toml:"tables,omitempty"`
32-
Views ViewIgnoreConfig `toml:"views,omitempty"`
33-
Functions FunctionIgnoreConfig `toml:"functions,omitempty"`
34-
Procedures ProcedureIgnoreConfig `toml:"procedures,omitempty"`
35-
Types TypeIgnoreConfig `toml:"types,omitempty"`
36-
Sequences SequenceIgnoreConfig `toml:"sequences,omitempty"`
31+
Tables TableIgnoreConfig `toml:"tables,omitempty"`
32+
Views ViewIgnoreConfig `toml:"views,omitempty"`
33+
Functions FunctionIgnoreConfig `toml:"functions,omitempty"`
34+
Procedures ProcedureIgnoreConfig `toml:"procedures,omitempty"`
35+
Types TypeIgnoreConfig `toml:"types,omitempty"`
36+
Sequences SequenceIgnoreConfig `toml:"sequences,omitempty"`
37+
Privileges PrivilegeIgnoreConfig `toml:"privileges,omitempty"`
38+
DefaultPrivileges DefaultPrivilegeIgnoreConfig `toml:"default_privileges,omitempty"`
3739
}
3840

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

71+
// PrivilegeIgnoreConfig represents privilege-specific ignore configuration
72+
// Patterns match on grantee role names
73+
type PrivilegeIgnoreConfig struct {
74+
Patterns []string `toml:"patterns,omitempty"`
75+
}
76+
77+
// DefaultPrivilegeIgnoreConfig represents default privilege-specific ignore configuration
78+
// Patterns match on grantee role names
79+
type DefaultPrivilegeIgnoreConfig struct {
80+
Patterns []string `toml:"patterns,omitempty"`
81+
}
82+
6983
// LoadIgnoreFileWithStructure loads the .pgschemaignore file using the structured TOML format
7084
// and converts it to the simple IgnoreConfig structure
7185
func LoadIgnoreFileWithStructure() (*ir.IgnoreConfig, error) {
@@ -91,12 +105,14 @@ func LoadIgnoreFileWithStructureFromPath(filePath string) (*ir.IgnoreConfig, err
91105

92106
// Convert to simple IgnoreConfig structure
93107
config := &ir.IgnoreConfig{
94-
Tables: tomlConfig.Tables.Patterns,
95-
Views: tomlConfig.Views.Patterns,
96-
Functions: tomlConfig.Functions.Patterns,
97-
Procedures: tomlConfig.Procedures.Patterns,
98-
Types: tomlConfig.Types.Patterns,
99-
Sequences: tomlConfig.Sequences.Patterns,
108+
Tables: tomlConfig.Tables.Patterns,
109+
Views: tomlConfig.Views.Patterns,
110+
Functions: tomlConfig.Functions.Patterns,
111+
Procedures: tomlConfig.Procedures.Patterns,
112+
Types: tomlConfig.Types.Patterns,
113+
Sequences: tomlConfig.Sequences.Patterns,
114+
Privileges: tomlConfig.Privileges.Patterns,
115+
DefaultPrivileges: tomlConfig.DefaultPrivileges.Patterns,
100116
}
101117

102118
return config, nil

cmd/util/ignoreloader_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,69 @@ patterns = ["fn_test_*"]
129129
}
130130
}
131131

132+
func TestLoadIgnoreFile_PrivilegeSections(t *testing.T) {
133+
tempDir := t.TempDir()
134+
testFile := filepath.Join(tempDir, "test.pgschemaignore")
135+
136+
tomlContent := `[privileges]
137+
patterns = ["deploy_bot", "admin_*", "!admin_super"]
138+
139+
[default_privileges]
140+
patterns = ["deploy_bot"]
141+
`
142+
143+
err := os.WriteFile(testFile, []byte(tomlContent), 0644)
144+
if err != nil {
145+
t.Fatalf("Failed to write test file: %v", err)
146+
}
147+
148+
config, err := LoadIgnoreFileFromPath(testFile)
149+
if err != nil {
150+
t.Fatalf("LoadIgnoreFileFromPath() error = %v", err)
151+
}
152+
if config == nil {
153+
t.Fatal("LoadIgnoreFileFromPath() returned nil config")
154+
}
155+
156+
// Test privileges section
157+
expectedPrivileges := []string{"deploy_bot", "admin_*", "!admin_super"}
158+
if len(config.Privileges) != len(expectedPrivileges) {
159+
t.Errorf("Expected %d privilege patterns, got %d", len(expectedPrivileges), len(config.Privileges))
160+
}
161+
for i, expected := range expectedPrivileges {
162+
if i < len(config.Privileges) && config.Privileges[i] != expected {
163+
t.Errorf("Expected privilege pattern %q at index %d, got %q", expected, i, config.Privileges[i])
164+
}
165+
}
166+
167+
// Test default_privileges section
168+
if len(config.DefaultPrivileges) != 1 || config.DefaultPrivileges[0] != "deploy_bot" {
169+
t.Errorf("Expected default_privileges patterns [\"deploy_bot\"], got %v", config.DefaultPrivileges)
170+
}
171+
172+
// Test ShouldIgnorePrivilege
173+
if !config.ShouldIgnorePrivilege("deploy_bot") {
174+
t.Error("deploy_bot should be ignored")
175+
}
176+
if !config.ShouldIgnorePrivilege("admin_role") {
177+
t.Error("admin_role should be ignored (matches admin_*)")
178+
}
179+
if config.ShouldIgnorePrivilege("admin_super") {
180+
t.Error("admin_super should NOT be ignored (negation pattern)")
181+
}
182+
if config.ShouldIgnorePrivilege("app_reader") {
183+
t.Error("app_reader should NOT be ignored")
184+
}
185+
186+
// Test ShouldIgnoreDefaultPrivilege
187+
if !config.ShouldIgnoreDefaultPrivilege("deploy_bot") {
188+
t.Error("deploy_bot default privilege should be ignored")
189+
}
190+
if config.ShouldIgnoreDefaultPrivilege("app_reader") {
191+
t.Error("app_reader default privilege should NOT be ignored")
192+
}
193+
}
194+
132195
func TestLoadIgnoreFile_InvalidTOML(t *testing.T) {
133196
// Create a temporary invalid TOML file
134197
tempDir := t.TempDir()

docs/cli/ignore.mdx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ The `.pgschemaignore` file allows you to exclude database objects from pgschema
1212
2. **Temporary Objects** - Exclude temp tables, debug views, and development-only objects
1313
3. **Legacy Objects** - Ignore deprecated objects while maintaining new schema management
1414
4. **Environment-Specific Objects** - Skip objects that exist only in certain environments
15+
5. **Role-Specific Privileges** - Ignore grants to roles that don't exist in the plan database
1516

1617
## File Format
1718

@@ -39,6 +40,12 @@ patterns = ["type_test_*"]
3940

4041
[sequences]
4142
patterns = ["seq_temp_*", "seq_debug_*"]
43+
44+
[privileges]
45+
patterns = ["deploy_bot", "admin_*"]
46+
47+
[default_privileges]
48+
patterns = ["deploy_bot"]
4249
```
4350

4451
## Pattern Syntax
@@ -79,6 +86,24 @@ patterns = [
7986

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

89+
## Privileges
90+
91+
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.
92+
93+
```toml
94+
[privileges]
95+
patterns = [
96+
"deploy_bot", # Ignore all grants to deploy_bot
97+
"admin_*", # Ignore grants to any admin_* role
98+
"!admin_super" # But keep grants to admin_super
99+
]
100+
101+
[default_privileges]
102+
patterns = ["deploy_bot"] # Ignore ALTER DEFAULT PRIVILEGES for deploy_bot
103+
```
104+
105+
The `[privileges]` section filters explicit grants (`GRANT ... TO role`), including column-level privileges. The `[default_privileges]` section filters `ALTER DEFAULT PRIVILEGES` statements.
106+
82107
## Triggers on Ignored Tables
83108

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

0 commit comments

Comments
 (0)