Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
139570: sql: Apply USING expression to up to one RLS policy r=spilchen a=spilchen

_Stacked PR_

## sql: Add RLS policy information to the optimizer catalog

Row-level security (RLS) policies include expressions that govern table access
during query execution. While policy information was previously added to the
table descriptor, this commit integrates it into the optimizer catalog. The
cat.Table interface has been updated to expose policy details, and a new
cat.Policy interface has been introduced to facilitate interaction with
individual policies.

Epic: CRDB-11724
Informs cockroachdb#136717
Release note: None

## sql: Add RLS statements to the opt test 

Row-level security (RLS) introduced new SQL grammar to manage policies and
enable RLS. This commit implements those statements in the optimizer test
catalog, allowing for optbuilder tests that utilize RLS features.

Newly supported statements:
- **CREATE POLICY:** Creates a new policy for a table.
- **DROP POLICY:** Removes an existing policy from a table.
- **ALTER TABLE .. {ENABLE|DISABLE} ROW LEVEL SECURITY:** Toggles RLS
enforcement for a table.
- **CREATE USER:** Adds support for creating users in the test catalog. RLS
policies are bypassed for admin members.
- **SET ROLE:** Enables changing the current user in optbuilder tests. The test
catalog now tracks the current user and the catalog's users.

Epic: CRDB-11724
Informs cockroachdb#136717
Release note: None

## sql: Apply RLS USING expressions during reads

Row-level security (RLS) allows you to define a USING expression in a policy,
which is applied to all read operations on tables with RLS enabled. A policy
specifies conditions, such as roles and SQL commands, that determine when it
applies. This commit adds support for incorporating a USING expression from at
most one permissive policy into a query.

Currently, the table attribute to enable RLS in the table descriptor is not yet
implemented. As a result, RLS effects are only observable in queries executed
via the optimizer test catalog.

All code paths that drive scans (e.g., SELECT, UPDATE, DELETE) now use
cat.PolicyCommandScope to determine which SQL operations RLS policies apply to.
Certain operations, such as foreign key checks and cascades, are explicitly
exempt from RLS enforcement.

Epic: CRDB-11724
Informs cockroachdb#136717
Release note: None


Co-authored-by: Matt Spilchen <[email protected]>
  • Loading branch information
craig[bot] and spilchen committed Jan 29, 2025
2 parents 2ce8b2c + e33ec1e commit df8a1e2
Show file tree
Hide file tree
Showing 21 changed files with 805 additions and 3 deletions.
2 changes: 2 additions & 0 deletions pkg/sql/opt/cat/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ go_library(
"family.go",
"index.go",
"object.go",
"policy.go",
"schema.go",
"sequence.go",
"table.go",
Expand All @@ -25,6 +26,7 @@ go_library(
"//pkg/geo/geopb",
"//pkg/roachpb",
"//pkg/security/username",
"//pkg/sql/catalog/catpb",
"//pkg/sql/catalog/descpb",
"//pkg/sql/pgwire/pgcode",
"//pkg/sql/pgwire/pgerror",
Expand Down
89 changes: 89 additions & 0 deletions pkg/sql/opt/cat/policy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Copyright 2025 The Cockroach Authors.
//
// Use of this software is governed by the CockroachDB Software License
// included in the /LICENSE file.

package cat

import (
"github.com/cockroachdb/cockroach/pkg/security/username"
"github.com/cockroachdb/cockroach/pkg/sql/catalog/catpb"
"github.com/cockroachdb/cockroach/pkg/sql/sem/tree"
)

// PolicyCommandScope specifies the scope of SQL commands to which a policy applies.
// It determines whether a policy is enforced for specific operations or if an operation
// is exempt from row-level security. The operations checked must align with the policy
// commands defined in the CREATE POLICY SQL syntax.
type PolicyCommandScope uint8

const (
// PolicyScopeSelect indicates that the policy applies to SELECT operations.
PolicyScopeSelect PolicyCommandScope = iota
// PolicyScopeInsert indicates that the policy applies to INSERT operations.
PolicyScopeInsert
// PolicyScopeUpdate indicates that the policy applies to UPDATE operations.
PolicyScopeUpdate
// PolicyScopeDelete indicates that the policy applies to DELETE operations.
PolicyScopeDelete
// PolicyScopeExempt indicates that the operation is exempt from row-level security policies.
PolicyScopeExempt
)

// Policy defines an interface for a row-level security (RLS) policy on a table.
// Policies use expressions to filter rows during read operations and/or restrict
// new rows during write operations.
type Policy struct {
// Name is the name of the policy. The name is unique within a table
// and cannot be qualified.
Name tree.Name
// UsingExpr is the optional filter expression evaluated on rows during
// read operations. If the policy does not define a USING expression, this is
// an empty string.
UsingExpr string
// WithCheckExpr is the optional validation expression applied to new rows
// during write operations. If the policy does not define a WITH CHECK expression,
// this is an empty string.
WithCheckExpr string
// Command is the command that the policy was defined for.
Command catpb.PolicyCommand
// roles are the roles the applies to. If the policy applies to all roles (aka
// public), this will be nil.
roles map[string]struct{}
}

// Policies contains the policies for a single table.
type Policies struct {
Permissive []Policy
Restrictive []Policy
}

// InitRoles builds up the list of roles in the policy.
func (p *Policy) InitRoles(roleNames []string) {
if len(roleNames) == 0 {
p.roles = nil
return
}
roles := make(map[string]struct{})
for _, r := range roleNames {
if r == username.PublicRole {
// If the public role is defined, there is no need to check the
// remaining roles since the policy applies to everyone. We will clear
// out the roles map to signal that all roles apply.
roles = nil
break
}
roles[r] = struct{}{}
}
p.roles = roles
}

// AppliesToRole checks whether the policy applies to the give role.
func (p *Policy) AppliesToRole(user username.SQLUsername) bool {
// If no roles are specified, assume the policy applies to all users (public role).
if p.roles == nil {
return true
}
_, found := p.roles[user.Normalized()]
return found
}
10 changes: 10 additions & 0 deletions pkg/sql/opt/cat/table.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,16 @@ type Table interface {

// Trigger returns the ith trigger, where i < TriggerCount.
Trigger(i int) Trigger

// IsRowLevelSecurityEnabled is true if policies should be applied during the query.
IsRowLevelSecurityEnabled() bool

// PolicyCount returns the number of policies in the table for the given type.
PolicyCount(polType tree.PolicyType) int

// Policy retrieves the policy of the specified type at the given index (i),
// where i < PolicyCount for the specified type.
Policy(polType tree.PolicyType, i int) Policy
}

// CheckConstraint represents a check constraint on a table. Check constraints
Expand Down
11 changes: 11 additions & 0 deletions pkg/sql/opt/exec/explain/plan_gist_factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,17 @@ func (u *unknownTable) Trigger(i int) cat.Trigger {
panic(errors.AssertionFailedf("not implemented"))
}

// IsRowLevelSecurityEnabled is part of the cat.Table interface
func (u *unknownTable) IsRowLevelSecurityEnabled() bool { return false }

// PolicyCount is part of the cat.Table interface
func (u *unknownTable) PolicyCount(polType tree.PolicyType) int { return 0 }

// Policy is part of the cat.Table interface
func (u *unknownTable) Policy(polType tree.PolicyType, i int) cat.Policy {
panic(errors.AssertionFailedf("not implemented"))
}

var _ cat.Table = &unknownTable{}

// unknownTable implements the cat.Index interface and is used to represent
Expand Down
1 change: 1 addition & 0 deletions pkg/sql/opt/optbuilder/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ go_library(
"plpgsql.go",
"project.go",
"routine.go",
"row_level_security.go",
"scalar.go",
"scope.go",
"scope_column.go",
Expand Down
7 changes: 6 additions & 1 deletion pkg/sql/opt/optbuilder/fk_cascade.go
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ func (cb *onDeleteFastCascadeBuilder) Build(
}

// Build the input to the delete mutation, which is simply a Scan with a
// Select on top.
// Select on top. The scan is exempt from RLS to maintain data integrity.
mb.fetchScope = b.buildScan(
b.addTable(cb.childTable, &mb.alias),
tableOrdinals(cb.childTable, columnKinds{
Expand All @@ -326,6 +326,7 @@ func (cb *onDeleteFastCascadeBuilder) Build(
noRowLocking,
b.allocScope(),
true, /* disableNotVisibleIndex */
cat.PolicyScopeExempt,
)
mb.outScope = mb.fetchScope

Expand Down Expand Up @@ -569,6 +570,7 @@ func (b *Builder) buildDeleteCascadeMutationInput(
indexFlags = &tree.IndexFlags{AvoidFullScan: true}
}

// The scan is exempt from RLS to maintain data integrity.
outScope = b.buildScan(
b.addTable(childTable, childTableAlias),
tableOrdinals(childTable, columnKinds{
Expand All @@ -580,6 +582,7 @@ func (b *Builder) buildDeleteCascadeMutationInput(
noRowLocking,
b.allocScope(),
true, /* disableNotVisibleIndex */
cat.PolicyScopeExempt,
)

numFKCols := fk.ColumnCount()
Expand Down Expand Up @@ -842,6 +845,7 @@ func (b *Builder) buildUpdateCascadeMutationInput(
indexFlags = &tree.IndexFlags{AvoidFullScan: true}
}

// The scan is exempt from RLS to maintain data integrity.
outScope = b.buildScan(
b.addTable(childTable, childTableAlias),
tableOrdinals(childTable, columnKinds{
Expand All @@ -853,6 +857,7 @@ func (b *Builder) buildUpdateCascadeMutationInput(
noRowLocking,
b.allocScope(),
true, /* disableNotVisibleIndex */
cat.PolicyScopeExempt,
)

numFKCols := fk.ColumnCount()
Expand Down
2 changes: 2 additions & 0 deletions pkg/sql/opt/optbuilder/mutation_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,7 @@ func (mb *mutationBuilder) buildInputForUpdate(
noRowLocking,
inScope,
false, /* disableNotVisibleIndex */
cat.PolicyScopeUpdate,
)

// Set list of columns that will be fetched by the input expression.
Expand Down Expand Up @@ -480,6 +481,7 @@ func (mb *mutationBuilder) buildInputForDelete(
noRowLocking,
inScope,
false, /* disableNotVisibleIndex */
cat.PolicyScopeDelete,
)

// Set list of columns that will be fetched by the input expression.
Expand Down
9 changes: 9 additions & 0 deletions pkg/sql/opt/optbuilder/mutation_builder_arbiter.go
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,9 @@ func (mb *mutationBuilder) buildAntiJoinForDoNothingArbiter(
locking,
inScope,
true, /* disableNotVisibleIndex */
// TODO(136704): Review and adjust the scope used here after implementing
// WITH CHECK to ensure correct filtering behavior for UPSERT operations.
cat.PolicyScopeExempt,
)

// If the index is a unique partial index, then rows that are not in the
Expand Down Expand Up @@ -474,6 +477,9 @@ func (mb *mutationBuilder) buildLeftJoinForUpsertArbiter(
locking,
inScope,
true, /* disableNotVisibleIndex */
// TODO(136704): Review and adjust the scope used here after implementing
// WITH CHECK to ensure correct filtering behavior for UPSERT operations.
cat.PolicyScopeExempt,
)
// Set fetchColIDs to reference the columns created for the fetch values.
mb.setFetchColIDs(mb.fetchScope.cols)
Expand Down Expand Up @@ -691,6 +697,9 @@ func (h *arbiterPredicateHelper) tableScope() *scope {
noRowLocking,
h.mb.b.allocScope(),
false, /* disableNotVisibleIndex */
// TODO(136704): Review and adjust the scope used here after implementing
// WITH CHECK to ensure correct filtering behavior for UPSERT operations.
cat.PolicyScopeExempt,
)
}
return h.tableScopeLazy
Expand Down
1 change: 1 addition & 0 deletions pkg/sql/opt/optbuilder/mutation_builder_fk.go
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,7 @@ func (h *fkCheckHelper) buildOtherTableScan(parent bool) (outScope *scope, tabMe
locking,
h.mb.b.allocScope(),
true, /* disableNotVisibleIndex */
cat.PolicyScopeExempt,
), otherTabMeta
}

Expand Down
2 changes: 2 additions & 0 deletions pkg/sql/opt/optbuilder/mutation_builder_unique.go
Original file line number Diff line number Diff line change
Expand Up @@ -659,13 +659,15 @@ func (h *uniqueCheckHelper) buildTableScan() (outScope *scope, ordinals []int) {
if h.mb.b.evalCtx.SessionData().AvoidFullTableScansInMutations {
indexFlags.AvoidFullScan = true
}
// The scan is exempt from RLS to maintain data integrity.
return h.mb.b.buildScan(
tabMeta,
ordinals,
indexFlags,
locking,
h.mb.b.allocScope(),
true, /* disableNotVisibleIndex */
cat.PolicyScopeExempt,
), ordinals
}

Expand Down
99 changes: 99 additions & 0 deletions pkg/sql/opt/optbuilder/row_level_security.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Copyright 2025 The Cockroach Authors.
//
// Use of this software is governed by the CockroachDB Software License
// included in the /LICENSE file.

package optbuilder

import (
"github.com/cockroachdb/cockroach/pkg/sql/catalog/catpb"
"github.com/cockroachdb/cockroach/pkg/sql/opt"
"github.com/cockroachdb/cockroach/pkg/sql/opt/cat"
"github.com/cockroachdb/cockroach/pkg/sql/opt/memo"
"github.com/cockroachdb/cockroach/pkg/sql/parser"
"github.com/cockroachdb/cockroach/pkg/sql/sem/tree"
"github.com/cockroachdb/cockroach/pkg/sql/types"
"github.com/cockroachdb/errors"
)

// addRowLevelSecurityFilter adds a filter based on the expressions of
// applicable RLS policies. If RLS is enabled but no policies are applicable,
// all rows will be filtered out.
func (b *Builder) addRowLevelSecurityFilter(
tabMeta *opt.TableMeta, tableScope *scope, cmdScope cat.PolicyCommandScope,
) {
if !tabMeta.Table.IsRowLevelSecurityEnabled() || cmdScope == cat.PolicyScopeExempt {
return
}

// Admin users are exempt from any RLS filtering.
isAdmin, err := b.catalog.HasAdminRole(b.ctx)
if err != nil {
panic(err)
}
if isAdmin {
return
}

scalar := b.buildRowLevelSecurityUsingExpression(tabMeta, tableScope, cmdScope)
tableScope.expr = b.factory.ConstructSelect(tableScope.expr,
memo.FiltersExpr{b.factory.ConstructFiltersItem(scalar)})
}

// buildRowLevelSecurityUsingExpression generates a scalar expression for read
// operations by combining all applicable RLS policies. An expression is always
// returned; if no policies apply, a 'false' expression is returned.
func (b *Builder) buildRowLevelSecurityUsingExpression(
tabMeta *opt.TableMeta, tableScope *scope, cmdScope cat.PolicyCommandScope,
) opt.ScalarExpr {
for i := 0; i < tabMeta.Table.PolicyCount(tree.PolicyTypePermissive); i++ {
policy := tabMeta.Table.Policy(tree.PolicyTypePermissive, i)

if !policy.AppliesToRole(b.checkPrivilegeUser) || !b.policyAppliesToCommandScope(policy, cmdScope) {
continue
}
strExpr := policy.UsingExpr
if strExpr == "" {
continue
}
parsedExpr, err := parser.ParseExpr(strExpr)
if err != nil {
panic(err)
}
typedExpr := tableScope.resolveType(parsedExpr, types.Any)
scalar := b.buildScalar(typedExpr, tableScope, nil, nil, nil)
// TODO(136742): Apply multiple RLS policies.
return scalar
}

// TODO(136742): Add support for restrictive policies.

// If no permissive policies apply, filter out all rows by adding a "false" expression.
return memo.FalseSingleton
}

// policyAppliesToCommandScope checks whether a given PolicyCommandScope applies
// to the specified policy. It returns true if the policy is applicable and
// false otherwise.
func (b *Builder) policyAppliesToCommandScope(
policy cat.Policy, cmdScope cat.PolicyCommandScope,
) bool {
if cmdScope == cat.PolicyScopeExempt {
return true
}
cmd := policy.Command
switch cmd {
case catpb.PolicyCommand_ALL:
return true
case catpb.PolicyCommand_SELECT:
return cmdScope == cat.PolicyScopeSelect
case catpb.PolicyCommand_INSERT:
return cmdScope == cat.PolicyScopeInsert
case catpb.PolicyCommand_UPDATE:
return cmdScope == cat.PolicyScopeUpdate
case catpb.PolicyCommand_DELETE:
return cmdScope == cat.PolicyScopeDelete
default:
panic(errors.AssertionFailedf("unknown policy command %v", cmd))
}
}
4 changes: 4 additions & 0 deletions pkg/sql/opt/optbuilder/select.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ func (b *Builder) buildDataSource(
}),
indexFlags, locking, inScope,
false, /* disableNotVisibleIndex */
cat.PolicyScopeSelect,
)

case cat.Sequence:
Expand Down Expand Up @@ -481,6 +482,7 @@ func (b *Builder) buildScanFromTableRef(
locking = b.lockingSpecForTableScan(locking, tabMeta)
return b.buildScan(
tabMeta, ordinals, indexFlags, locking, inScope, false, /* disableNotVisibleIndex */
cat.PolicyScopeSelect,
)
}

Expand Down Expand Up @@ -532,6 +534,7 @@ func (b *Builder) buildScan(
locking lockingSpec,
inScope *scope,
disableNotVisibleIndex bool,
policyCommandScope cat.PolicyCommandScope,
) (outScope *scope) {
if ordinals == nil {
panic(errors.AssertionFailedf("no ordinals"))
Expand Down Expand Up @@ -749,6 +752,7 @@ func (b *Builder) buildScan(
// Add the partial indexes after constructing the scan so we can use the
// logical properties of the scan to fully normalize the index predicates.
b.addPartialIndexPredicatesForTable(tabMeta, outScope.expr)
b.addRowLevelSecurityFilter(tabMeta, outScope, policyCommandScope)

if !virtualColIDs.Empty() {
// Project the expressions for the virtual columns (and pass through all
Expand Down
Loading

0 comments on commit df8a1e2

Please sign in to comment.