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;