From 6a6204729df3328cccc13f888c7bdca178ef0b98 Mon Sep 17 00:00:00 2001 From: Azim Sonawalla Date: Wed, 14 Jan 2026 09:25:10 -0500 Subject: [PATCH] feat: add support for column-level GRANT privileges Column-level privileges like `GRANT SELECT (col1, col2) ON TABLE t TO role` were previously silently ignored during schema inspection. This meant that column grants defined in schema files would not be detected or included in migration plans, causing privilege drift between desired and actual state. This change adds full support for column-level privileges: - Add ColumnPrivilege struct to IR with proper identity (table + columns + grantee) - Add GetColumnPrivilegesForSchema query to extract from pg_attribute.attacl - Add buildColumnPrivileges to inspector to resolve grantee OIDs and group columns - Add DiffTypeColumnPrivilege and comparison logic in diff package - Add column_privilege.go with GRANT/REVOKE DDL generation - Update formatter and plan output to display column privileges Column privileges are now properly detected, diffed, and included in migration plans alongside table-level privileges. --- internal/diff/column_privilege.go | 264 ++++++++++++++++++ internal/diff/diff.go | 123 ++++++++ internal/dump/formatter.go | 5 +- internal/plan/plan.go | 2 + ir/inspector.go | 123 ++++++++ ir/ir.go | 42 ++- ir/queries/queries.sql | 28 +- ir/queries/queries.sql.go | 64 +++++ .../privilege/grant_table_select/diff.sql | 1 + .../diff/privilege/grant_table_select/new.sql | 7 + .../diff/privilege/grant_table_select/old.sql | 3 + .../privilege/grant_table_select/plan.json | 6 + .../privilege/grant_table_select/plan.sql | 2 + .../privilege/grant_table_select/plan.txt | 8 +- 14 files changed, 673 insertions(+), 5 deletions(-) create mode 100644 internal/diff/column_privilege.go diff --git a/internal/diff/column_privilege.go b/internal/diff/column_privilege.go new file mode 100644 index 00000000..aed3e855 --- /dev/null +++ b/internal/diff/column_privilege.go @@ -0,0 +1,264 @@ +package diff + +import ( + "fmt" + "sort" + "strings" + + "github.com/pgschema/pgschema/ir" +) + +// generateCreateColumnPrivilegesSQL generates GRANT statements for new column privileges +func generateCreateColumnPrivilegesSQL(privileges []*ir.ColumnPrivilege, targetSchema string, collector *diffCollector) { + for _, cp := range privileges { + sql := generateGrantColumnPrivilegeSQL(cp) + + // Path format: column_privileges.TABLE.{table_name}.{columns}.{grantee} + sortedCols := make([]string, len(cp.Columns)) + copy(sortedCols, cp.Columns) + sort.Strings(sortedCols) + colKey := strings.Join(sortedCols, ",") + + context := &diffContext{ + Type: DiffTypeColumnPrivilege, + Operation: DiffOperationCreate, + Path: fmt.Sprintf("column_privileges.TABLE.%s.%s.%s", cp.TableName, colKey, cp.Grantee), + Source: cp, + CanRunInTransaction: true, + } + + collector.collect(context, sql) + } +} + +// generateDropColumnPrivilegesSQL generates REVOKE statements for removed column privileges +func generateDropColumnPrivilegesSQL(privileges []*ir.ColumnPrivilege, targetSchema string, collector *diffCollector) { + for _, cp := range privileges { + sql := generateRevokeColumnPrivilegeSQL(cp) + + sortedCols := make([]string, len(cp.Columns)) + copy(sortedCols, cp.Columns) + sort.Strings(sortedCols) + colKey := strings.Join(sortedCols, ",") + + context := &diffContext{ + Type: DiffTypeColumnPrivilege, + Operation: DiffOperationDrop, + Path: fmt.Sprintf("column_privileges.TABLE.%s.%s.%s", cp.TableName, colKey, cp.Grantee), + Source: cp, + CanRunInTransaction: true, + } + + collector.collect(context, sql) + } +} + +// generateModifyColumnPrivilegesSQL generates ALTER column privilege statements for modifications +func generateModifyColumnPrivilegesSQL(diffs []*columnPrivilegeDiff, targetSchema string, collector *diffCollector) { + for _, diff := range diffs { + statements := diff.generateAlterColumnPrivilegeStatements() + + sortedCols := make([]string, len(diff.New.Columns)) + copy(sortedCols, diff.New.Columns) + sort.Strings(sortedCols) + colKey := strings.Join(sortedCols, ",") + + for _, stmt := range statements { + context := &diffContext{ + Type: DiffTypeColumnPrivilege, + Operation: DiffOperationAlter, + Path: fmt.Sprintf("column_privileges.TABLE.%s.%s.%s", diff.New.TableName, colKey, diff.New.Grantee), + Source: diff, + CanRunInTransaction: true, + } + + collector.collect(context, stmt) + } + } +} + +// generateGrantColumnPrivilegeSQL generates a GRANT statement for column privileges +func generateGrantColumnPrivilegeSQL(cp *ir.ColumnPrivilege) string { + // Sort privileges for deterministic output + sortedPrivs := make([]string, len(cp.Privileges)) + copy(sortedPrivs, cp.Privileges) + sort.Strings(sortedPrivs) + + privStr := strings.Join(sortedPrivs, ", ") + + // Format columns with proper quoting + quotedCols := make([]string, len(cp.Columns)) + for i, col := range cp.Columns { + quotedCols[i] = ir.QuoteIdentifier(col) + } + sort.Strings(quotedCols) + colStr := strings.Join(quotedCols, ", ") + + grantee := formatGrantee(cp.Grantee) + tableName := ir.QuoteIdentifier(cp.TableName) + + sql := fmt.Sprintf("GRANT %s (%s) ON TABLE %s TO %s", privStr, colStr, tableName, grantee) + + if cp.WithGrantOption { + sql += " WITH GRANT OPTION" + } + + return sql + ";" +} + +// generateRevokeColumnPrivilegeSQL generates a REVOKE statement for column privileges +func generateRevokeColumnPrivilegeSQL(cp *ir.ColumnPrivilege) string { + // Sort privileges for deterministic output + sortedPrivs := make([]string, len(cp.Privileges)) + copy(sortedPrivs, cp.Privileges) + sort.Strings(sortedPrivs) + + privStr := strings.Join(sortedPrivs, ", ") + + // Format columns with proper quoting + quotedCols := make([]string, len(cp.Columns)) + for i, col := range cp.Columns { + quotedCols[i] = ir.QuoteIdentifier(col) + } + sort.Strings(quotedCols) + colStr := strings.Join(quotedCols, ", ") + + grantee := formatGrantee(cp.Grantee) + tableName := ir.QuoteIdentifier(cp.TableName) + + return fmt.Sprintf("REVOKE %s (%s) ON TABLE %s FROM %s;", privStr, colStr, tableName, grantee) +} + +// generateAlterColumnPrivilegeStatements generates statements for column privilege modifications +func (d *columnPrivilegeDiff) generateAlterColumnPrivilegeStatements() []string { + var statements []string + + // Find privileges to revoke (in old but not in new) + oldPrivSet := make(map[string]bool) + for _, p := range d.Old.Privileges { + oldPrivSet[p] = true + } + newPrivSet := make(map[string]bool) + for _, p := range d.New.Privileges { + newPrivSet[p] = true + } + + var toRevoke []string + for p := range oldPrivSet { + if !newPrivSet[p] { + toRevoke = append(toRevoke, p) + } + } + + var toGrant []string + for p := range newPrivSet { + if !oldPrivSet[p] { + toGrant = append(toGrant, p) + } + } + + grantee := formatGrantee(d.New.Grantee) + tableName := ir.QuoteIdentifier(d.New.TableName) + + // Format columns with proper quoting + quotedCols := make([]string, len(d.New.Columns)) + for i, col := range d.New.Columns { + quotedCols[i] = ir.QuoteIdentifier(col) + } + sort.Strings(quotedCols) + colStr := strings.Join(quotedCols, ", ") + + // Generate REVOKE for removed privileges + if len(toRevoke) > 0 { + sort.Strings(toRevoke) + statements = append(statements, fmt.Sprintf("REVOKE %s (%s) ON TABLE %s FROM %s;", + strings.Join(toRevoke, ", "), colStr, tableName, grantee)) + } + + // Generate GRANT for added privileges + if len(toGrant) > 0 { + sort.Strings(toGrant) + sql := fmt.Sprintf("GRANT %s (%s) ON TABLE %s TO %s", + strings.Join(toGrant, ", "), colStr, tableName, grantee) + if d.New.WithGrantOption { + sql += " WITH GRANT OPTION" + } + statements = append(statements, sql+";") + } + + // Handle WITH GRANT OPTION changes for unchanged privileges + if d.Old.WithGrantOption != d.New.WithGrantOption { + // Find unchanged privileges (in both old and new) + var unchanged []string + for p := range oldPrivSet { + if newPrivSet[p] { + unchanged = append(unchanged, p) + } + } + + if len(unchanged) > 0 { + sort.Strings(unchanged) + unchangedStr := strings.Join(unchanged, ", ") + + if d.Old.WithGrantOption && !d.New.WithGrantOption { + // Revoke grant option only (keep the privilege) + statements = append(statements, fmt.Sprintf("REVOKE GRANT OPTION FOR %s (%s) ON TABLE %s FROM %s;", + unchangedStr, colStr, tableName, grantee)) + } else if !d.Old.WithGrantOption && d.New.WithGrantOption { + // Add grant option (re-grant with grant option) + statements = append(statements, fmt.Sprintf("GRANT %s (%s) ON TABLE %s TO %s WITH GRANT OPTION;", + unchangedStr, colStr, tableName, grantee)) + } + } + } + + return statements +} + +// columnPrivilegesEqual checks if two column privileges are structurally equal +func columnPrivilegesEqual(old, new *ir.ColumnPrivilege) bool { + if old.TableName != new.TableName { + return false + } + if old.Grantee != new.Grantee { + return false + } + if old.WithGrantOption != new.WithGrantOption { + return false + } + + // Compare columns (order-independent) + if len(old.Columns) != len(new.Columns) { + return false + } + oldColSet := make(map[string]bool) + for _, c := range old.Columns { + oldColSet[c] = true + } + for _, c := range new.Columns { + if !oldColSet[c] { + return false + } + } + + // Compare privileges (order-independent) + if len(old.Privileges) != len(new.Privileges) { + return false + } + oldPrivSet := make(map[string]bool) + for _, p := range old.Privileges { + oldPrivSet[p] = true + } + for _, p := range new.Privileges { + if !oldPrivSet[p] { + return false + } + } + + return true +} + +// GetObjectName returns a unique identifier for the column privilege diff +func (d *columnPrivilegeDiff) GetObjectName() string { + return d.New.GetObjectKey() +} diff --git a/internal/diff/diff.go b/internal/diff/diff.go index d3527b10..b34d5c52 100644 --- a/internal/diff/diff.go +++ b/internal/diff/diff.go @@ -39,6 +39,7 @@ const ( DiffTypeDefaultPrivilege DiffTypePrivilege DiffTypeRevokedDefaultPrivilege + DiffTypeColumnPrivilege ) // String returns the string representation of DiffType @@ -94,6 +95,8 @@ func (d DiffType) String() string { return "privilege" case DiffTypeRevokedDefaultPrivilege: return "revoked_default_privilege" + case DiffTypeColumnPrivilege: + return "column_privilege" default: return "unknown" } @@ -162,6 +165,8 @@ func (d *DiffType) UnmarshalJSON(data []byte) error { *d = DiffTypePrivilege case "revoked_default_privilege": *d = DiffTypeRevokedDefaultPrivilege + case "column_privilege": + *d = DiffTypeColumnPrivilege default: return fmt.Errorf("unknown diff type: %s", s) } @@ -275,6 +280,10 @@ type ddlDiff struct { modifiedPrivileges []*privilegeDiff addedRevokedDefaultPrivs []*ir.RevokedDefaultPrivilege droppedRevokedDefaultPrivs []*ir.RevokedDefaultPrivilege + // Column-level privileges + addedColumnPrivileges []*ir.ColumnPrivilege + droppedColumnPrivileges []*ir.ColumnPrivilege + modifiedColumnPrivileges []*columnPrivilegeDiff } // schemaDiff represents changes to a schema @@ -319,6 +328,12 @@ type privilegeDiff struct { New *ir.Privilege } +// columnPrivilegeDiff represents changes to a column-level privilege +type columnPrivilegeDiff struct { + Old *ir.ColumnPrivilege + New *ir.ColumnPrivilege +} + // triggerDiff represents changes to a trigger type triggerDiff struct { Old *ir.Trigger @@ -425,6 +440,9 @@ func GenerateMigration(oldIR, newIR *ir.IR, targetSchema string) []Diff { modifiedPrivileges: []*privilegeDiff{}, addedRevokedDefaultPrivs: []*ir.RevokedDefaultPrivilege{}, droppedRevokedDefaultPrivs: []*ir.RevokedDefaultPrivilege{}, + addedColumnPrivileges: []*ir.ColumnPrivilege{}, + droppedColumnPrivileges: []*ir.ColumnPrivilege{}, + modifiedColumnPrivileges: []*columnPrivilegeDiff{}, } // Compare schemas first in deterministic order @@ -1144,6 +1162,102 @@ func GenerateMigration(oldIR, newIR *ir.IR, targetSchema string) []Diff { return diff.droppedRevokedDefaultPrivs[i].GetObjectKey() < diff.droppedRevokedDefaultPrivs[j].GetObjectKey() }) + // Compare column privileges across all schemas + oldColPrivs := make(map[string]*ir.ColumnPrivilege) + newColPrivs := make(map[string]*ir.ColumnPrivilege) + + for _, dbSchema := range oldIR.Schemas { + for _, cp := range dbSchema.ColumnPrivileges { + key := cp.GetFullKey() + oldColPrivs[key] = cp + } + } + + for _, dbSchema := range newIR.Schemas { + for _, cp := range dbSchema.ColumnPrivileges { + key := cp.GetFullKey() + newColPrivs[key] = cp + } + } + + // Build index by GetObjectKey() for modification detection + oldColPrivsByObjectKey := make(map[string][]*ir.ColumnPrivilege) + newColPrivsByObjectKey := make(map[string][]*ir.ColumnPrivilege) + for _, cp := range oldColPrivs { + key := cp.GetObjectKey() + oldColPrivsByObjectKey[key] = append(oldColPrivsByObjectKey[key], cp) + } + for _, cp := range newColPrivs { + key := cp.GetObjectKey() + newColPrivsByObjectKey[key] = append(newColPrivsByObjectKey[key], cp) + } + + // Track which column privileges have been matched + matchedOldColPrivs := make(map[string]bool) + matchedNewColPrivs := make(map[string]bool) + + // Find modified column privileges + for objectKey, newList := range newColPrivsByObjectKey { + oldList := oldColPrivsByObjectKey[objectKey] + if len(oldList) == 0 { + continue + } + + // Simple case: one privilege each + if len(oldList) == 1 && len(newList) == 1 { + oldCP, newCP := oldList[0], newList[0] + if !columnPrivilegesEqual(oldCP, newCP) { + diff.modifiedColumnPrivileges = append(diff.modifiedColumnPrivileges, &columnPrivilegeDiff{ + Old: oldCP, + New: newCP, + }) + } + matchedOldColPrivs[oldCP.GetFullKey()] = true + matchedNewColPrivs[newCP.GetFullKey()] = true + continue + } + + // Complex case: match by full key + for _, newCP := range newList { + fullKey := newCP.GetFullKey() + if oldCP, exists := oldColPrivs[fullKey]; exists { + if !columnPrivilegesEqual(oldCP, newCP) { + diff.modifiedColumnPrivileges = append(diff.modifiedColumnPrivileges, &columnPrivilegeDiff{ + Old: oldCP, + New: newCP, + }) + } + matchedOldColPrivs[fullKey] = true + matchedNewColPrivs[fullKey] = true + } + } + } + + // Find added column privileges + for fullKey, cp := range newColPrivs { + if !matchedNewColPrivs[fullKey] { + diff.addedColumnPrivileges = append(diff.addedColumnPrivileges, cp) + } + } + + // Find dropped column privileges + for fullKey, cp := range oldColPrivs { + if !matchedOldColPrivs[fullKey] { + diff.droppedColumnPrivileges = append(diff.droppedColumnPrivileges, cp) + } + } + + // Sort column privileges for deterministic output + sort.Slice(diff.addedColumnPrivileges, func(i, j int) bool { + return diff.addedColumnPrivileges[i].GetObjectKey() < diff.addedColumnPrivileges[j].GetObjectKey() + }) + sort.Slice(diff.droppedColumnPrivileges, func(i, j int) bool { + return diff.droppedColumnPrivileges[i].GetObjectKey() < diff.droppedColumnPrivileges[j].GetObjectKey() + }) + sort.Slice(diff.modifiedColumnPrivileges, func(i, j int) bool { + return diff.modifiedColumnPrivileges[i].New.GetObjectKey() < diff.modifiedColumnPrivileges[j].New.GetObjectKey() + }) + // Sort tables and views topologically for consistent ordering // Pre-sort by name to ensure deterministic insertion order for cycle breaking sort.Slice(diff.addedTables, func(i, j int) bool { @@ -1354,6 +1468,9 @@ func (d *ddlDiff) generateCreateSQL(targetSchema string, collector *diffCollecto // Create explicit object privileges generateCreatePrivilegesSQL(d.addedPrivileges, targetSchema, collector) + // Create column-level privileges + generateCreateColumnPrivilegesSQL(d.addedColumnPrivileges, targetSchema, collector) + // Revoke default PUBLIC privileges (new revokes) generateRevokeDefaultPrivilegesSQL(d.addedRevokedDefaultPrivs, targetSchema, collector) } @@ -1387,6 +1504,9 @@ func (d *ddlDiff) generateModifySQL(targetSchema string, collector *diffCollecto // Modify explicit object privileges generateModifyPrivilegesSQL(d.modifiedPrivileges, targetSchema, collector) + + // Modify column-level privileges + generateModifyColumnPrivilegesSQL(d.modifiedColumnPrivileges, targetSchema, collector) } // generateDropSQL generates DROP statements in reverse dependency order @@ -1418,6 +1538,9 @@ func (d *ddlDiff) generateDropSQL(targetSchema string, collector *diffCollector, // Restore default PUBLIC privileges (dropped revokes = restore defaults) generateRestoreDefaultPrivilegesSQL(d.droppedRevokedDefaultPrivs, targetSchema, collector) + // Drop column-level privileges + generateDropColumnPrivilegesSQL(d.droppedColumnPrivileges, targetSchema, collector) + // Drop explicit object privileges generateDropPrivilegesSQL(d.droppedPrivileges, targetSchema, collector) diff --git a/internal/dump/formatter.go b/internal/dump/formatter.go index 791c392d..00aa8b24 100644 --- a/internal/dump/formatter.go +++ b/internal/dump/formatter.go @@ -242,7 +242,7 @@ func (f *DumpFormatter) getObjectDirectory(objectType string) string { return "tables" // fallback, will be overridden case "default_privilege": return "default_privileges" - case "privilege", "revoked_default_privilege": + case "privilege", "revoked_default_privilege", "column_privilege": return "privileges" default: return "misc" @@ -352,6 +352,9 @@ func (f *DumpFormatter) getGroupingName(step diff.Diff) string { if parts := strings.Split(step.Path, "."); len(parts) >= 2 { return parts[1] // Return object type } + case diff.DiffTypeColumnPrivilege: + // For column privileges, group by TABLE (always table-based) + return "TABLE" } // For standalone objects or if table name extraction fails, use object name diff --git a/internal/plan/plan.go b/internal/plan/plan.go index 6f6c8bcb..eba446c0 100644 --- a/internal/plan/plan.go +++ b/internal/plan/plan.go @@ -102,6 +102,7 @@ const ( TypeRLS Type = "rls" TypeDefaultPrivilege Type = "default privileges" TypePrivilege Type = "privileges" + TypeColumnPrivilege Type = "column privileges" TypeRevokedDefaultPrivilege Type = "revoked default privileges" ) @@ -133,6 +134,7 @@ func getObjectOrder() []Type { TypeColumn, TypeRLS, TypePrivilege, + TypeColumnPrivilege, TypeRevokedDefaultPrivilege, } } diff --git a/ir/inspector.go b/ir/inspector.go index 7da2d2f4..37c481b8 100644 --- a/ir/inspector.go +++ b/ir/inspector.go @@ -90,6 +90,7 @@ func (i *Inspector) BuildIR(ctx context.Context, targetSchema string) (*IR, erro i.buildTypes, i.buildDefaultPrivileges, i.buildPrivileges, + i.buildColumnPrivileges, }, } @@ -2202,3 +2203,125 @@ func getSequenceMaxValueForType(dataType string) int64 { return bigintMaxValue } } + +// buildColumnPrivileges retrieves column-level privilege grants for the schema +func (i *Inspector) buildColumnPrivileges(ctx context.Context, schema *IR, targetSchema string) error { + rows, err := i.queries.GetColumnPrivilegesForSchema(ctx, sql.NullString{String: targetSchema, Valid: true}) + if err != nil { + return fmt.Errorf("failed to query column privileges: %w", err) + } + + // Group by (table, grantee, privilege_type, is_grantable) to collect columns + // Then further group by column set to combine privilege types + type colPrivKey struct { + TableName string + Grantee string + WithGrantOption bool + } + + // First, collect all column->privileges for each (table, grantee, grant_option) + type columnPrivs struct { + Columns map[string][]string // column_name -> []privilege_type + Privileges map[string]bool // unique privilege types + } + grouped := make(map[colPrivKey]*columnPrivs) + + for _, row := range rows { + tableName := row.TableName + columnName := row.ColumnName + privilegeType := row.PrivilegeType.String + isGrantable := row.IsGrantable.Valid && row.IsGrantable.Bool + + // Determine grantee name from OID + grantee := "PUBLIC" + if row.GranteeOid != nil { + var granteeOID int64 + switch v := row.GranteeOid.(type) { + case int64: + granteeOID = v + case int32: + granteeOID = int64(v) + case int: + granteeOID = int64(v) + } + if granteeOID != 0 { + var roleName string + err := i.db.QueryRowContext(ctx, "SELECT rolname FROM pg_roles WHERE oid = $1", granteeOID).Scan(&roleName) + if err != nil { + if err == sql.ErrNoRows { + continue // Role no longer exists, skip this privilege + } + return fmt.Errorf("failed to lookup role name for OID %d: %w", granteeOID, err) + } + grantee = roleName + } + } + + key := colPrivKey{ + TableName: tableName, + Grantee: grantee, + WithGrantOption: isGrantable, + } + + if grouped[key] == nil { + grouped[key] = &columnPrivs{ + Columns: make(map[string][]string), + Privileges: make(map[string]bool), + } + } + + grouped[key].Columns[columnName] = append(grouped[key].Columns[columnName], privilegeType) + grouped[key].Privileges[privilegeType] = true + } + + // Convert to ColumnPrivilege structs + // Group columns that have the same set of privileges + var columnPrivileges []*ColumnPrivilege + for key, cp := range grouped { + // For each unique set of privileges, collect columns that have exactly those privileges + // First, normalize: for each column, get its sorted privilege set as a key + privSetToColumns := make(map[string][]string) + for col, privs := range cp.Columns { + sort.Strings(privs) + privKey := strings.Join(privs, ",") + privSetToColumns[privKey] = append(privSetToColumns[privKey], col) + } + + // Create a ColumnPrivilege for each unique privilege set + for privKey, cols := range privSetToColumns { + sort.Strings(cols) // Sort columns for deterministic output + privs := strings.Split(privKey, ",") + + p := &ColumnPrivilege{ + TableName: key.TableName, + Columns: cols, + Grantee: key.Grantee, + Privileges: privs, + WithGrantOption: key.WithGrantOption, + } + columnPrivileges = append(columnPrivileges, p) + } + } + + // Sort for deterministic output + sort.Slice(columnPrivileges, func(i, j int) bool { + if columnPrivileges[i].TableName != columnPrivileges[j].TableName { + return columnPrivileges[i].TableName < columnPrivileges[j].TableName + } + // Sort by first column name for determinism + iCols := strings.Join(columnPrivileges[i].Columns, ",") + jCols := strings.Join(columnPrivileges[j].Columns, ",") + if iCols != jCols { + return iCols < jCols + } + return columnPrivileges[i].Grantee < columnPrivileges[j].Grantee + }) + + // Assign to schema + s, ok := schema.GetSchema(targetSchema) + if ok { + s.ColumnPrivileges = columnPrivileges + } + + return nil +} diff --git a/ir/ir.go b/ir/ir.go index 71a5858d..55e1d6ee 100644 --- a/ir/ir.go +++ b/ir/ir.go @@ -1,6 +1,7 @@ package ir import ( + "sort" "strings" "sync" ) @@ -29,8 +30,9 @@ type Schema struct { Aggregates map[string]*Aggregate `json:"aggregates"` // aggregate_name -> Aggregate Sequences map[string]*Sequence `json:"sequences"` // sequence_name -> Sequence Types map[string]*Type `json:"types"` // type_name -> Type - DefaultPrivileges []*DefaultPrivilege `json:"default_privileges,omitempty"` // Default privileges for future objects - Privileges []*Privilege `json:"privileges,omitempty"` // Explicit privilege grants on objects + DefaultPrivileges []*DefaultPrivilege `json:"default_privileges,omitempty"` // Default privileges for future objects + Privileges []*Privilege `json:"privileges,omitempty"` // Explicit privilege grants on objects + ColumnPrivileges []*ColumnPrivilege `json:"column_privileges,omitempty"` // Column-level privilege grants RevokedDefaultPrivileges []*RevokedDefaultPrivilege `json:"revoked_default_privileges,omitempty"` // Explicit revokes of default PUBLIC privileges mu sync.RWMutex // Protects concurrent access to all maps } @@ -489,6 +491,42 @@ func (r *RevokedDefaultPrivilege) GetObjectName() string { return r.ObjectName } +// ColumnPrivilege represents a column-level privilege grant on a table +// Column-level grants allow fine-grained access control on specific columns +// rather than the entire table. Stored in pg_attribute.attacl. +type ColumnPrivilege struct { + TableName string `json:"table_name"` // table containing the columns + Columns []string `json:"columns"` // columns for this grant (sorted alphabetically) + Grantee string `json:"grantee"` // role name or "PUBLIC" + Privileges []string `json:"privileges"` // SELECT, INSERT, UPDATE, REFERENCES only + WithGrantOption bool `json:"with_grant_option"` // Can grantee grant to others? +} + +// GetObjectKey returns a unique identifier for the column privilege. +// MUST include sorted columns - two grants on same table/role with different columns are different objects. +func (cp *ColumnPrivilege) GetObjectKey() string { + sortedCols := make([]string, len(cp.Columns)) + copy(sortedCols, cp.Columns) + sort.Strings(sortedCols) + colKey := strings.Join(sortedCols, ",") + return "COLUMN:" + cp.TableName + ":" + colKey + ":" + cp.Grantee +} + +// GetFullKey returns a unique identifier including grant option. +// Use this when you need to distinguish between the same privilege with different grant options. +func (cp *ColumnPrivilege) GetFullKey() string { + grantOption := "0" + if cp.WithGrantOption { + grantOption = "1" + } + return cp.GetObjectKey() + ":" + grantOption +} + +// GetObjectName returns the table name for the column privilege +func (cp *ColumnPrivilege) GetObjectName() string { + return cp.TableName +} + // NewIR creates a new empty catalog IR func NewIR() *IR { return &IR{ diff --git a/ir/queries/queries.sql b/ir/queries/queries.sql index 318a7a61..83da41dc 100644 --- a/ir/queries/queries.sql +++ b/ir/queries/queries.sql @@ -1360,4 +1360,30 @@ public_grants AS ( SELECT object_name, object_type FROM public_grants WHERE has_explicit_acl = true AND has_public_grant = false -ORDER BY object_type, object_name; \ No newline at end of file +ORDER BY object_type, object_name; + +-- GetColumnPrivilegesForSchema retrieves column-level privilege grants +-- Column privileges are stored in pg_attribute.attacl and allow fine-grained access +-- name: GetColumnPrivilegesForSchema :many +WITH column_acls AS ( + SELECT + c.relname AS table_name, + a.attname AS column_name, + a.attacl AS acl + FROM pg_attribute a + JOIN pg_class c ON a.attrelid = c.oid + JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE n.nspname = $1 + AND c.relkind IN ('r', 'v', 'm') -- tables, views, materialized views + AND a.attnum > 0 -- skip system columns + AND NOT a.attisdropped + AND a.attacl IS NOT NULL -- only columns with explicit ACL +) +SELECT + table_name, + column_name, + (aclexplode(acl)).grantee AS grantee_oid, + (aclexplode(acl)).privilege_type AS privilege_type, + (aclexplode(acl)).is_grantable AS is_grantable +FROM column_acls +ORDER BY table_name, column_name, grantee_oid, privilege_type; \ No newline at end of file diff --git a/ir/queries/queries.sql.go b/ir/queries/queries.sql.go index 391001df..151a41a7 100644 --- a/ir/queries/queries.sql.go +++ b/ir/queries/queries.sql.go @@ -190,6 +190,70 @@ func (q *Queries) GetAggregatesForSchema(ctx context.Context, dollar_1 sql.NullS return items, nil } +const getColumnPrivilegesForSchema = `-- name: GetColumnPrivilegesForSchema :many +WITH column_acls AS ( + SELECT + c.relname AS table_name, + a.attname AS column_name, + a.attacl AS acl + FROM pg_attribute a + JOIN pg_class c ON a.attrelid = c.oid + JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE n.nspname = $1 + AND c.relkind IN ('r', 'v', 'm') -- tables, views, materialized views + AND a.attnum > 0 -- skip system columns + AND NOT a.attisdropped + AND a.attacl IS NOT NULL -- only columns with explicit ACL +) +SELECT + table_name, + column_name, + (aclexplode(acl)).grantee AS grantee_oid, + (aclexplode(acl)).privilege_type AS privilege_type, + (aclexplode(acl)).is_grantable AS is_grantable +FROM column_acls +ORDER BY table_name, column_name, grantee_oid, privilege_type +` + +type GetColumnPrivilegesForSchemaRow struct { + TableName string `db:"table_name" json:"table_name"` + ColumnName string `db:"column_name" json:"column_name"` + GranteeOid interface{} `db:"grantee_oid" json:"grantee_oid"` + PrivilegeType sql.NullString `db:"privilege_type" json:"privilege_type"` + IsGrantable sql.NullBool `db:"is_grantable" json:"is_grantable"` +} + +// GetColumnPrivilegesForSchema retrieves column-level privilege grants +// Column privileges are stored in pg_attribute.attacl and allow fine-grained access +func (q *Queries) GetColumnPrivilegesForSchema(ctx context.Context, dollar_1 sql.NullString) ([]GetColumnPrivilegesForSchemaRow, error) { + rows, err := q.db.QueryContext(ctx, getColumnPrivilegesForSchema, dollar_1) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetColumnPrivilegesForSchemaRow + for rows.Next() { + var i GetColumnPrivilegesForSchemaRow + if err := rows.Scan( + &i.TableName, + &i.ColumnName, + &i.GranteeOid, + &i.PrivilegeType, + &i.IsGrantable, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getColumns = `-- name: GetColumns :many WITH column_base AS ( SELECT diff --git a/testdata/diff/privilege/grant_table_select/diff.sql b/testdata/diff/privilege/grant_table_select/diff.sql index b3a2836a..fd6372f8 100644 --- a/testdata/diff/privilege/grant_table_select/diff.sql +++ b/testdata/diff/privilege/grant_table_select/diff.sql @@ -1 +1,2 @@ GRANT SELECT ON TABLE users TO readonly_role; +GRANT SELECT (id) ON TABLE users TO column_reader; diff --git a/testdata/diff/privilege/grant_table_select/new.sql b/testdata/diff/privilege/grant_table_select/new.sql index 2dfed5bf..c0d77ef3 100644 --- a/testdata/diff/privilege/grant_table_select/new.sql +++ b/testdata/diff/privilege/grant_table_select/new.sql @@ -3,8 +3,15 @@ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'readonly_role') THEN CREATE ROLE readonly_role; END IF; + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'column_reader') THEN + CREATE ROLE column_reader; + END IF; END $$; CREATE TABLE users (id serial PRIMARY KEY); +-- Table-level grant (existing) GRANT SELECT ON users TO readonly_role; + +-- Column-level grant (new - tests column privilege support) +GRANT SELECT (id) ON users TO column_reader; diff --git a/testdata/diff/privilege/grant_table_select/old.sql b/testdata/diff/privilege/grant_table_select/old.sql index 831b850d..c3f0b768 100644 --- a/testdata/diff/privilege/grant_table_select/old.sql +++ b/testdata/diff/privilege/grant_table_select/old.sql @@ -3,6 +3,9 @@ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'readonly_role') THEN CREATE ROLE readonly_role; END IF; + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'column_reader') THEN + CREATE ROLE column_reader; + END IF; END $$; CREATE TABLE users (id serial PRIMARY KEY); diff --git a/testdata/diff/privilege/grant_table_select/plan.json b/testdata/diff/privilege/grant_table_select/plan.json index 746b2e23..98629b76 100644 --- a/testdata/diff/privilege/grant_table_select/plan.json +++ b/testdata/diff/privilege/grant_table_select/plan.json @@ -13,6 +13,12 @@ "type": "privilege", "operation": "create", "path": "privileges.TABLE.users.readonly_role" + }, + { + "sql": "GRANT SELECT (id) ON TABLE users TO column_reader;", + "type": "column_privilege", + "operation": "create", + "path": "column_privileges.TABLE.users.id.column_reader" } ] } diff --git a/testdata/diff/privilege/grant_table_select/plan.sql b/testdata/diff/privilege/grant_table_select/plan.sql index b3a2836a..b3f1ecbb 100644 --- a/testdata/diff/privilege/grant_table_select/plan.sql +++ b/testdata/diff/privilege/grant_table_select/plan.sql @@ -1 +1,3 @@ GRANT SELECT ON TABLE users TO readonly_role; + +GRANT SELECT (id) ON TABLE users TO column_reader; diff --git a/testdata/diff/privilege/grant_table_select/plan.txt b/testdata/diff/privilege/grant_table_select/plan.txt index 8e27cf46..763efd6e 100644 --- a/testdata/diff/privilege/grant_table_select/plan.txt +++ b/testdata/diff/privilege/grant_table_select/plan.txt @@ -1,12 +1,18 @@ -Plan: 1 to add. +Plan: 2 to add. Summary by type: privileges: 1 to add + column privileges: 1 to add Privileges: + readonly_role +Column privileges: + + column_reader + DDL to be executed: -------------------------------------------------- GRANT SELECT ON TABLE users TO readonly_role; + +GRANT SELECT (id) ON TABLE users TO column_reader;