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
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,6 @@ Unsupported: <= 13 are not supported. Use at your own risk.

# Unsupported migrations
An abridged list of unsupported migrations:
- Privileges (Planned)
- Types (Only enums are currently supported)
- Renaming. The diffing library relies on names to identify the old and new versions of a table, index, etc. If you rename
an object, it will be treated as a drop and an add
Expand Down
2 changes: 1 addition & 1 deletion cmd/pg-schema-diff/apply_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ func runPlan(ctx context.Context, cmd *cobra.Command, connConfig *pgx.ConnConfig
return fmt.Errorf("setting lock timeout: %w", err)
}
if _, err := conn.ExecContext(ctx, stmt.ToSQL()); err != nil {
return fmt.Errorf("executing migration statement. the database maybe be in a dirty state: %s: %w", stmt, err)
return fmt.Errorf("executing migration statement. the database maybe be in a dirty state: %s: %w", stmt.DDL, err)
}
cmdPrintf(cmd, "Finished executing statement. Duration: %s\n", time.Since(start))
}
Expand Down
230 changes: 230 additions & 0 deletions internal/migration_acceptance_tests/privilege_cases_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
package migration_acceptance_tests

import (
"testing"

"github.com/stripe/pg-schema-diff/pkg/diff"
)

var privilegeAcceptanceTestCases = []acceptanceTestCase{
{
name: "no-op",
roles: []string{
"app_user",
},
oldSchemaDDL: []string{
`
CREATE TABLE foobar(id INT);
GRANT SELECT ON foobar TO app_user;
`,
},
newSchemaDDL: []string{
`
CREATE TABLE foobar(id INT);
GRANT SELECT ON foobar TO app_user;
`,
},
expectEmptyPlan: true,
},
{
name: "Grant multiple privileges to role",
roles: []string{"app_user"},
oldSchemaDDL: []string{
`CREATE TABLE foobar(id INT);`,
},
newSchemaDDL: []string{
`
CREATE TABLE foobar(id INT);
GRANT SELECT, INSERT, UPDATE, DELETE ON foobar TO app_user;
`,
},
expectedHazardTypes: []diff.MigrationHazardType{
diff.MigrationHazardTypeAuthzUpdate,
},
},
{
name: "Revoke privilege from role",
roles: []string{"app_user"},
oldSchemaDDL: []string{
`
CREATE TABLE foobar(id INT);
GRANT SELECT ON foobar TO app_user;
`,
},
newSchemaDDL: []string{
`CREATE TABLE foobar(id INT);`,
},
expectedHazardTypes: []diff.MigrationHazardType{
diff.MigrationHazardTypeAuthzUpdate,
},
},
{
name: "Grant WITH GRANT OPTION",
roles: []string{"app_user"},
oldSchemaDDL: []string{
`CREATE TABLE foobar(id INT);`,
},
newSchemaDDL: []string{
`
CREATE TABLE foobar(id INT);
GRANT SELECT ON foobar TO app_user WITH GRANT OPTION;
`,
},
expectedHazardTypes: []diff.MigrationHazardType{
diff.MigrationHazardTypeAuthzUpdate,
},
},
{
name: "Change GRANT OPTION (recreates privilege)",
roles: []string{"app_user"},
oldSchemaDDL: []string{
`
CREATE TABLE foobar(id INT);
GRANT SELECT ON foobar TO app_user;
`,
},
newSchemaDDL: []string{
`
CREATE TABLE foobar(id INT);
GRANT SELECT ON foobar TO app_user WITH GRANT OPTION;
`,
},
expectedHazardTypes: []diff.MigrationHazardType{
diff.MigrationHazardTypeAuthzUpdate,
},
},
{
name: "Remove GRANT OPTION (recreates privilege)",
roles: []string{"app_user"},
oldSchemaDDL: []string{
`
CREATE TABLE foobar(id INT);
GRANT SELECT ON foobar TO app_user WITH GRANT OPTION;
`,
},
newSchemaDDL: []string{
`
CREATE TABLE foobar(id INT);
GRANT SELECT ON foobar TO app_user;
`,
},
expectedHazardTypes: []diff.MigrationHazardType{
diff.MigrationHazardTypeAuthzUpdate,
},
},
{
name: "Grant on new table (no hazards since table is new)",
roles: []string{"app_user"},
newSchemaDDL: []string{
`
CREATE TABLE foobar(id INT);
GRANT SELECT ON foobar TO app_user;
`,
},
// No hazards expected since table is brand new
},
{
name: "Drop table with privileges (only DeletesData hazard)",
roles: []string{"app_user"},
oldSchemaDDL: []string{
`
CREATE TABLE foobar(id INT);
GRANT SELECT ON foobar TO app_user;
`,
},
expectedHazardTypes: []diff.MigrationHazardType{
diff.MigrationHazardTypeDeletesData,
},
},
{
name: "Grant on non-public schema table",
roles: []string{"app_user"},
oldSchemaDDL: []string{
`
CREATE SCHEMA app_schema;
CREATE TABLE app_schema.foobar(id INT);
`,
},
newSchemaDDL: []string{
`
CREATE SCHEMA app_schema;
CREATE TABLE app_schema.foobar(id INT);
GRANT SELECT ON app_schema.foobar TO app_user;
`,
},
expectedHazardTypes: []diff.MigrationHazardType{
diff.MigrationHazardTypeAuthzUpdate,
},
},
{
name: "Grant on partitioned parent table",
roles: []string{"app_user"},
oldSchemaDDL: []string{
`
CREATE TABLE foobar(
category TEXT
) partition by list (category);
CREATE TABLE foobar_1 PARTITION OF foobar FOR VALUES IN ('category_1');
`,
},
newSchemaDDL: []string{
`
CREATE TABLE foobar(
category TEXT
) partition by list (category);
CREATE TABLE foobar_1 PARTITION OF foobar FOR VALUES IN ('category_1');
GRANT SELECT ON foobar TO app_user;
`,
},
expectedHazardTypes: []diff.MigrationHazardType{
diff.MigrationHazardTypeAuthzUpdate,
},
},
{
name: "Privilege on new partition (not implemented)",
roles: []string{"app_user"},
oldSchemaDDL: []string{
`
CREATE TABLE foobar(
category TEXT
) partition by list (category);
`,
},
newSchemaDDL: []string{
`
CREATE TABLE foobar(
category TEXT
) partition by list (category);
CREATE TABLE foobar_1 PARTITION OF foobar FOR VALUES IN ('category');
GRANT SELECT ON foobar_1 TO app_user;
`,
},
expectedPlanErrorIs: diff.ErrNotImplemented,
},
{
name: "Add privilege on existing partition (not implemented)",
roles: []string{"app_user"},
oldSchemaDDL: []string{
`
CREATE TABLE foobar(
category TEXT
) partition by list (category);
CREATE TABLE foobar_1 PARTITION OF foobar FOR VALUES IN ('category');
`,
},
newSchemaDDL: []string{
`
CREATE TABLE foobar(
category TEXT
) partition by list (category);
CREATE TABLE foobar_1 PARTITION OF foobar FOR VALUES IN ('category');
GRANT SELECT ON foobar_1 TO app_user;
`,
},
expectedPlanErrorIs: diff.ErrNotImplemented,
},
}

func TestPrivilegeCases(t *testing.T) {
runTestCases(t, privilegeAcceptanceTestCases)
}
42 changes: 42 additions & 0 deletions internal/queries/queries.sql
Original file line number Diff line number Diff line change
Expand Up @@ -624,3 +624,45 @@ WHERE
AND depend.objid = c.oid
AND depend.deptype = 'e'
);

-- name: GetTablePrivileges :many
WITH parsed_acl AS (
SELECT
c.oid AS table_oid,
c.relname AS table_name,
n.nspname AS table_schema_name,
c.relowner AS owner_oid,
(ACLEXPLODE(c.relacl)).grantee AS grantee_oid,
(ACLEXPLODE(c.relacl)).privilege_type AS privilege_type,
(ACLEXPLODE(c.relacl)).is_grantable AS is_grantable
FROM pg_catalog.pg_class AS c
INNER JOIN pg_catalog.pg_namespace AS n ON c.relnamespace = n.oid
WHERE
n.nspname NOT IN ('pg_catalog', 'information_schema')
AND n.nspname !~ '^pg_toast'
AND n.nspname !~ '^pg_temp'
AND (c.relkind = 'r' OR c.relkind = 'p')
AND c.relacl IS NOT null
-- Exclude tables owned by extensions
AND NOT EXISTS (
SELECT depend.objid
FROM pg_catalog.pg_depend AS depend
WHERE
depend.classid = 'pg_class'::REGCLASS
AND depend.objid = c.oid
AND depend.deptype = 'e'
)
)

SELECT
pa.table_name::TEXT,
pa.table_schema_name::TEXT,
COALESCE(grantee_role.rolname, '')::TEXT AS grantee,
pa.privilege_type::TEXT AS privilege,
pa.is_grantable
FROM parsed_acl AS pa
LEFT JOIN pg_catalog.pg_roles AS grantee_role
ON pa.grantee_oid = grantee_role.oid
-- Exclude privileges granted to the table owner (these are implicit)
WHERE pa.grantee_oid != pa.owner_oid OR pa.grantee_oid = 0
ORDER BY pa.table_schema_name, pa.table_name, grantee, pa.privilege_type;
80 changes: 80 additions & 0 deletions internal/queries/queries.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading