Skip to content

Commit d40f497

Browse files
Apply security row and query filters in metrics view Summary (#9717)
* Apply security row and query filters in metrics view summary The summary executor built its SQL by hand and never applied the security policy's row filter or query filter, so dimension example/min/max values were computed over all rows regardless of row-level security. - Add metricsview.SecurityFilterSQL to compile the policy's row filter and query filter into a WHERE expression for the underlying table. - Apply it in Executor.Summary to both the categorical summary query and the time dimension min/max resolution (the sample window is derived from the max timestamp, so it must be filtered too). - Include the claims' AdditionalRules and SkipChecks in the resolver result cache key; previously two magic auth tokens with different row filters but identical user attributes shared cached results. * Address review feedback - Revert the security filtering of time ranges in the summary: per the earlier decision on time expressions, min/max timestamps intentionally remain unfiltered so they stay metadata-only operations and cache across users. Document this on Timestamps. If a user's accessible rows are all older than the sample interval, the summary values are null. - Move the security filter compilation out of buildWhereForUnderlyingTable into SecurityFilterSQL and place it with the exported functions. - Skip hashing the claims' additional rules in the resolver cache key when SkipChecks is true. User attributes stay in the key even with SkipChecks because they also drive templating ({{ .user }}), e.g. for ManageInstances claims and skip_nested_security APIs which carry real user attributes.
1 parent 8065daf commit d40f497

4 files changed

Lines changed: 172 additions & 21 deletions

File tree

runtime/metricsview/ast.go

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,38 @@ func NewAST(mv *runtimev1.MetricsViewSpec, sec MetricsViewSecurity, qry *Query,
363363
return ast, nil
364364
}
365365

366+
// SecurityFilterSQL compiles the security policy's query filter and row filter for the given metrics view
367+
// into a SQL expression and args that can be used in a WHERE clause against the metrics view's underlying table.
368+
// It returns an empty SQL string if the security policy does not restrict row access.
369+
// It is used when building an AST, and can also be used directly for queries that are built manually, such as dimension summaries.
370+
func SecurityFilterSQL(mv *runtimev1.MetricsViewSpec, sec MetricsViewSecurity, dialect drivers.Dialect) (string, []any, error) {
371+
var res *ExprNode
372+
373+
if qf := sec.QueryFilter(); qf != nil {
374+
// Compiling an expression requires an AST value for contextual info such as dimension lookups.
375+
a := &AST{
376+
MetricsView: mv,
377+
Security: sec,
378+
Query: &Query{},
379+
Dialect: dialect,
380+
}
381+
expr, args, err := a.SQLForExpression(NewExpressionFromProto(qf), nil, false, false)
382+
if err != nil {
383+
return "", nil, fmt.Errorf("failed to compile the security policy's query filter: %w", err)
384+
}
385+
res = res.And(expr, args)
386+
}
387+
388+
if rf := sec.RowFilter(); rf != "" {
389+
res = res.And(rf, nil)
390+
}
391+
392+
if res == nil {
393+
return "", nil, nil
394+
}
395+
return res.Expr, res.Args, nil
396+
}
397+
366398
// ResolveDimension returns a dimension spec for the given dimension query.
367399
// If the dimension query specifies a computed dimension, it constructs a dimension spec to match it.
368400
func (a *AST) ResolveDimension(qd Dimension, visible bool) (*runtimev1.MetricsViewSpec_Dimension, error) {
@@ -1022,7 +1054,7 @@ func (a *AST) addReferencedMeasuresToScope(n *SelectNode, referencedMeasures []s
10221054
}
10231055

10241056
// buildWhereForUnderlyingTable constructs an expression for a WHERE clause for the underlying table.
1025-
// It combines the provided where expression with any security policy filters.
1057+
// It combines the provided where expression with the security policy's filters.
10261058
// It allows the input `where` to be nil, and returns nil if there are no conditions to apply.
10271059
func (a *AST) buildWhereForUnderlyingTable(where *Expression) (*ExprNode, error) {
10281060
var res *ExprNode
@@ -1033,18 +1065,11 @@ func (a *AST) buildWhereForUnderlyingTable(where *Expression) (*ExprNode, error)
10331065
}
10341066
res = res.And(expr, args)
10351067

1036-
if qf := a.Security.QueryFilter(); qf != nil {
1037-
e := NewExpressionFromProto(qf)
1038-
expr, args, err = a.SQLForExpression(e, nil, false, false)
1039-
if err != nil {
1040-
return nil, fmt.Errorf("failed to compile the security policy's query filter: %w", err)
1041-
}
1042-
res = res.And(expr, args)
1043-
}
1044-
1045-
if rf := a.Security.RowFilter(); rf != "" {
1046-
res = res.And(rf, nil)
1068+
secExpr, secArgs, err := SecurityFilterSQL(a.MetricsView, a.Security, a.Dialect)
1069+
if err != nil {
1070+
return nil, err
10471071
}
1072+
res = res.And(secExpr, secArgs)
10481073

10491074
return res, nil
10501075
}

runtime/metricsview/executor/executor.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,10 @@ func (e *Executor) ValidateQuery(qry *metricsview.Query) error {
170170

171171
// Timestamps queries min, max and watermark for the metrics view.
172172
// For the primary time dimension it also resolves rollup table timestamps if rollups are present.
173+
// It intentionally does not apply security policy row filters:
174+
// unfiltered timestamps can be computed from database metadata and cached across users,
175+
// and they keep time expressions evaluating consistently for all users.
176+
// The trade-off is that users whose accessible rows don't span the full range may see "no data" for some time ranges.
173177
func (e *Executor) Timestamps(ctx context.Context, timeDim string) (metricsview.TimestampsResult, error) {
174178
if timeDim == "" {
175179
timeDim = e.metricsView.TimeDimension

runtime/metricsview/executor/executor_summary.go

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
runtimev1 "github.com/rilldata/rill/proto/gen/rill/runtime/v1"
1010
"github.com/rilldata/rill/runtime"
1111
"github.com/rilldata/rill/runtime/drivers"
12+
"github.com/rilldata/rill/runtime/metricsview"
1213
)
1314

1415
const (
@@ -42,6 +43,12 @@ func (e *Executor) Summary(ctx context.Context) (*SummaryResult, error) {
4243
return nil, runtime.ErrForbidden
4344
}
4445

46+
// Compile the security policy's row filter and query filter so the summary only reflects rows the user can access
47+
securityFilter, securityFilterArgs, err := metricsview.SecurityFilterSQL(e.metricsView, e.security, e.olap.Dialect())
48+
if err != nil {
49+
return nil, fmt.Errorf("failed to compile the security policy's filters: %w", err)
50+
}
51+
4552
// Gather the categorical and time dimensions
4653
var dimensions, timeDimensions []*runtimev1.MetricsViewSpec_Dimension
4754
for _, dim := range e.metricsView.Dimensions {
@@ -85,7 +92,9 @@ func (e *Executor) Summary(ctx context.Context) (*SummaryResult, error) {
8592
timeDimensions = timeDimensions[0:SummaryTimeDimensionsLimit]
8693
}
8794

88-
// Compute the time dimension summaries
95+
// Compute the time dimension summaries.
96+
// Note that Timestamps intentionally does not apply the security policy's filters (see its docstring),
97+
// so unlike the dimension summaries below, the time ranges reflect all rows in the underlying table.
8998
var summaries []DimensionSummary
9099
var defaultTimeDimensionSummary DimensionSummary
91100
for _, dim := range timeDimensions {
@@ -149,26 +158,32 @@ func (e *Executor) Summary(ctx context.Context) (*SummaryResult, error) {
149158
}
150159
}
151160

152-
// Create a where clause that applies the SummarySampleInterval to the default time dimension
153-
var whereClause string
161+
// Create a where clause that applies the SummarySampleInterval to the default time dimension and the security policy's filters
162+
var whereClauses []string
154163
var args []any
155164
if timeDimExpr != "" && defaultTimeDimensionSummary.MaxValue != nil {
156165
maxTime, _ := defaultTimeDimensionSummary.MaxValue.(time.Time)
157166
if !maxTime.IsZero() {
158-
whereClause = fmt.Sprintf("WHERE %s >= ?", timeDimExpr)
159-
args = []any{maxTime.Add(-SummarySampleInterval)}
167+
whereClauses = append(whereClauses, fmt.Sprintf("%s >= ?", timeDimExpr))
168+
args = append(args, maxTime.Add(-SummarySampleInterval))
160169
}
161170
}
171+
// Note the sample interval is derived from the unfiltered max timestamp,
172+
// so if the user's accessible rows are all older than the sample interval, the summary values will be null.
173+
if securityFilter != "" {
174+
whereClauses = append(whereClauses, fmt.Sprintf("(%s)", securityFilter))
175+
args = append(args, securityFilterArgs...)
176+
}
162177

163178
// Build the SQL query
164179
var sqlBuilder strings.Builder
165180
sqlBuilder.WriteString("SELECT ")
166181
sqlBuilder.WriteString(strings.Join(selectClauses, ", "))
167182
sqlBuilder.WriteString(" FROM ")
168183
sqlBuilder.WriteString(e.olap.Dialect().EscapeTable(e.metricsView.Database, e.metricsView.DatabaseSchema, e.metricsView.Table))
169-
if whereClause != "" {
170-
sqlBuilder.WriteString(" ")
171-
sqlBuilder.WriteString(whereClause)
184+
if len(whereClauses) > 0 {
185+
sqlBuilder.WriteString(" WHERE ")
186+
sqlBuilder.WriteString(strings.Join(whereClauses, " AND "))
172187
}
173188
sqlBuilder.WriteString(" LIMIT 1")
174189
sql := sqlBuilder.String()

runtime/resolvers/testdata/metrics_summary.yaml

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,21 @@ project_files:
6666
- if: "'{{ .user.role }}' != 'admin'"
6767
names:
6868
- country
69+
clickhouse_metrics_restricted.yaml:
70+
type: metrics_view
71+
model: clickhouse_test_data
72+
timeseries: event_time
73+
dimensions:
74+
- name: category
75+
column: category
76+
- name: country
77+
column: country
78+
measures:
79+
- name: total_revenue
80+
expression: sum(revenue)
81+
security:
82+
access: true
83+
row_filter: "country = '{{ .user.country }}'"
6984
duckdb_metrics_view.yaml:
7085
type: metrics_view
7186
model: duckdb_test_data
@@ -209,13 +224,35 @@ tests:
209224
max_value: "2024-01-08T00:00:00Z"
210225
min_value: "2024-01-01T00:00:00Z"
211226
name: event_time
227+
# The time range is intentionally unfiltered (see the Timestamps docstring), and since this user's accessible
228+
# rows are all older than the sample interval, the dimension summary values are null (instead of leaking values).
212229
- name: duckdb_summary_with_security
213230
resolver: metrics_summary
214231
properties:
215232
metrics_view: duckdb_metrics_restricted
216233
user_attributes:
217234
country: "US"
218235
role: "viewer"
236+
result:
237+
- dimensions:
238+
- data_type: CODE_TIMESTAMP
239+
max_value: "2024-01-08T00:00:00Z"
240+
min_value: "2024-01-01T00:00:00Z"
241+
name: event_time
242+
- data_type: CODE_STRING
243+
name: category
244+
time_range:
245+
data_type: CODE_TIMESTAMP
246+
max_value: "2024-01-08T00:00:00Z"
247+
min_value: "2024-01-01T00:00:00Z"
248+
name: event_time
249+
- name: duckdb_summary_with_security_admin
250+
resolver: metrics_summary
251+
properties:
252+
metrics_view: duckdb_metrics_restricted
253+
user_attributes:
254+
country: "CA"
255+
role: "admin"
219256
result:
220257
- dimensions:
221258
- data_type: CODE_TIMESTAMP
@@ -225,8 +262,78 @@ tests:
225262
- data_type: CODE_STRING
226263
example_value: Electronics
227264
max_value: Electronics
228-
min_value: Clothing
265+
min_value: Electronics
229266
name: category
267+
- data_type: CODE_STRING
268+
example_value: CA
269+
max_value: CA
270+
min_value: CA
271+
name: country
272+
time_range:
273+
data_type: CODE_TIMESTAMP
274+
max_value: "2024-01-08T00:00:00Z"
275+
min_value: "2024-01-01T00:00:00Z"
276+
name: event_time
277+
- name: duckdb_summary_with_additional_row_filter
278+
resolver: metrics_summary
279+
properties:
280+
metrics_view: duckdb_metrics_view
281+
additional_rules:
282+
- row_filter: "country = 'CA'"
283+
result:
284+
- dimensions:
285+
- data_type: CODE_TIMESTAMP
286+
max_value: "2024-01-08T00:00:00Z"
287+
min_value: "2024-01-01T00:00:00Z"
288+
name: event_time
289+
- data_type: CODE_STRING
290+
example_value: CA
291+
max_value: CA
292+
min_value: CA
293+
name: country
294+
- data_type: CODE_STRING
295+
example_value: Electronics
296+
max_value: Electronics
297+
min_value: Electronics
298+
name: category
299+
- data_type: CODE_STRING
300+
has_nulls: true
301+
name: product
302+
- data_type: CODE_STRING
303+
has_nulls: true
304+
name: channel
305+
- data_type: CODE_BOOL
306+
example_value: true
307+
max_value: true
308+
min_value: true
309+
name: is_active
310+
time_range:
311+
data_type: CODE_TIMESTAMP
312+
max_value: "2024-01-08T00:00:00Z"
313+
min_value: "2024-01-01T00:00:00Z"
314+
name: event_time
315+
- name: clickhouse_summary_with_security
316+
resolver: metrics_summary
317+
properties:
318+
metrics_view: clickhouse_metrics_restricted
319+
user_attributes:
320+
country: "CA"
321+
result:
322+
- dimensions:
323+
- data_type: CODE_TIMESTAMP
324+
max_value: "2024-01-08T00:00:00Z"
325+
min_value: "2024-01-01T00:00:00Z"
326+
name: event_time
327+
- data_type: CODE_STRING
328+
example_value: Electronics
329+
max_value: Electronics
330+
min_value: Electronics
331+
name: category
332+
- data_type: CODE_STRING
333+
example_value: CA
334+
max_value: CA
335+
min_value: CA
336+
name: country
230337
time_range:
231338
data_type: CODE_TIMESTAMP
232339
max_value: "2024-01-08T00:00:00Z"

0 commit comments

Comments
 (0)