Skip to content
Draft
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
231 changes: 231 additions & 0 deletions internal/migration_acceptance_tests/column_privilege_cases_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
package migration_acceptance_tests

import (
"testing"

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

var columnPrivilegeAcceptanceTestCases = []acceptanceTestCase{
{
name: "no-op",
roles: []string{
"app_user",
},
oldSchemaDDL: []string{
`
CREATE TABLE foobar(id INT, data TEXT);
GRANT SELECT (id) ON foobar TO app_user;
`,
},
newSchemaDDL: []string{
`
CREATE TABLE foobar(id INT, data TEXT);
GRANT SELECT (id) ON foobar TO app_user;
`,
},
expectEmptyPlan: true,
},
{
name: "Grant column privileges on new table (no hazards since table is new)",
roles: []string{"app_user"},
newSchemaDDL: []string{
`
CREATE TABLE foobar(id INT, data TEXT);
GRANT SELECT (data), UPDATE (data) ON foobar TO app_user;
`,
},
// No hazards expected since table is brand new
},
{
name: "Grant column privilege on existing table",
roles: []string{"app_user"},
oldSchemaDDL: []string{
`CREATE TABLE foobar(id INT, data TEXT);`,
},
newSchemaDDL: []string{
`
CREATE TABLE foobar(id INT, data TEXT);
GRANT SELECT (data) ON foobar TO app_user;
`,
},
expectedHazardTypes: []diff.MigrationHazardType{
diff.MigrationHazardTypeAuthzUpdate,
},
},
{
name: "Grant column privilege on multiple columns",
roles: []string{"app_user"},
oldSchemaDDL: []string{
`CREATE TABLE foobar(id INT, data TEXT, extra TEXT);`,
},
newSchemaDDL: []string{
`
CREATE TABLE foobar(id INT, data TEXT, extra TEXT);
GRANT SELECT (data, extra) ON foobar TO app_user;
`,
},
expectedHazardTypes: []diff.MigrationHazardType{
diff.MigrationHazardTypeAuthzUpdate,
},
},
{
name: "Revoke column privilege",
roles: []string{"app_user"},
oldSchemaDDL: []string{
`
CREATE TABLE foobar(id INT, data TEXT);
GRANT SELECT (data) ON foobar TO app_user;
`,
},
newSchemaDDL: []string{
`CREATE TABLE foobar(id INT, data TEXT);`,
},
expectedHazardTypes: []diff.MigrationHazardType{
diff.MigrationHazardTypeAuthzUpdate,
},
},
{
name: "Grant column privilege on new column",
roles: []string{"app_user"},
oldSchemaDDL: []string{
`CREATE TABLE foobar(id INT);`,
},
newSchemaDDL: []string{
`
CREATE TABLE foobar(id INT, data TEXT);
GRANT SELECT (data) ON foobar TO app_user;
`,
},
// The column must be added before its privilege is granted.
expectedPlanDDL: []string{
`ALTER TABLE "public"."foobar" ADD COLUMN "data" text COLLATE "pg_catalog"."default"`,
`GRANT SELECT ("data") ON "public"."foobar" TO "app_user"`,
},
expectedHazardTypes: []diff.MigrationHazardType{
diff.MigrationHazardTypeAuthzUpdate,
},
},
{
name: "Drop column with column privilege (only DeletesData hazard)",
roles: []string{"app_user"},
oldSchemaDDL: []string{
`
CREATE TABLE foobar(id INT, data TEXT);
GRANT SELECT (data) ON foobar TO app_user;
`,
},
newSchemaDDL: []string{
`CREATE TABLE foobar(id INT);`,
},
// The privilege is dropped together with the column, so no revoke is emitted.
expectedHazardTypes: []diff.MigrationHazardType{
diff.MigrationHazardTypeDeletesData,
},
},
{
name: "Change column privilege GRANT OPTION (recreates privilege)",
roles: []string{"app_user"},
oldSchemaDDL: []string{
`
CREATE TABLE foobar(id INT, data TEXT);
GRANT SELECT (data) ON foobar TO app_user;
`,
},
newSchemaDDL: []string{
`
CREATE TABLE foobar(id INT, data TEXT);
GRANT SELECT (data) ON foobar TO app_user WITH GRANT OPTION;
`,
},
expectedHazardTypes: []diff.MigrationHazardType{
diff.MigrationHazardTypeAuthzUpdate,
},
},
{
name: "Remove column privilege GRANT OPTION (recreates privilege)",
roles: []string{"app_user"},
oldSchemaDDL: []string{
`
CREATE TABLE foobar(id INT, data TEXT);
GRANT SELECT (data) ON foobar TO app_user WITH GRANT OPTION;
`,
},
newSchemaDDL: []string{
`
CREATE TABLE foobar(id INT, data TEXT);
GRANT SELECT (data) ON foobar TO app_user;
`,
},
expectedHazardTypes: []diff.MigrationHazardType{
diff.MigrationHazardTypeAuthzUpdate,
},
},
{
name: "Column privilege on non-public schema table",
roles: []string{"app_user"},
oldSchemaDDL: []string{
`
CREATE SCHEMA app_schema;
CREATE TABLE app_schema.foobar(id INT, data TEXT);
`,
},
newSchemaDDL: []string{
`
CREATE SCHEMA app_schema;
CREATE TABLE app_schema.foobar(id INT, data TEXT);
GRANT SELECT (data) ON app_schema.foobar TO app_user;
`,
},
expectedHazardTypes: []diff.MigrationHazardType{
diff.MigrationHazardTypeAuthzUpdate,
},
},
{
name: "Column 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 (category) ON foobar_1 TO app_user;
`,
},
expectedPlanErrorIs: diff.ErrNotImplemented,
},
{
name: "Add column 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 (category) ON foobar_1 TO app_user;
`,
},
expectedPlanErrorIs: diff.ErrNotImplemented,
},
}

func TestColumnPrivilegeCases(t *testing.T) {
runTestCases(t, columnPrivilegeAcceptanceTestCases)
}
47 changes: 47 additions & 0 deletions internal/queries/queries.sql
Original file line number Diff line number Diff line change
Expand Up @@ -666,3 +666,50 @@ LEFT JOIN pg_catalog.pg_roles AS grantee_role
-- 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;

-- name: GetColumnPrivileges :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,
a.attname AS column_name,
(ACLEXPLODE(a.attacl)).grantee AS grantee_oid,
(ACLEXPLODE(a.attacl)).privilege_type AS privilege_type,
(ACLEXPLODE(a.attacl)).is_grantable AS is_grantable
FROM pg_catalog.pg_attribute AS a
INNER JOIN pg_catalog.pg_class AS c ON a.attrelid = c.oid
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 a.attacl IS NOT NULL
AND a.attnum > 0
AND NOT a.attisdropped
-- 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,
pa.column_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, pa.column_name, grantee, pa.privilege_type;
87 changes: 87 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