From 309885a3337dde98e525930416160ad6625f96de Mon Sep 17 00:00:00 2001 From: Nishant Bangarwa Date: Mon, 20 Jul 2026 17:56:15 +0530 Subject: [PATCH 1/2] 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. --- runtime/metricsview/ast.go | 22 ++++ runtime/metricsview/executor/executor.go | 4 +- .../metricsview/executor/executor_summary.go | 53 ++++++-- .../executor/executor_timestamps.go | 55 ++++++--- runtime/resolver.go | 20 +++ .../resolvers/testdata/metrics_summary.yaml | 114 +++++++++++++++++- 6 files changed, 241 insertions(+), 27 deletions(-) diff --git a/runtime/metricsview/ast.go b/runtime/metricsview/ast.go index 4556886f6545..09ae95a13cb8 100644 --- a/runtime/metricsview/ast.go +++ b/runtime/metricsview/ast.go @@ -1049,6 +1049,28 @@ func (a *AST) buildWhereForUnderlyingTable(where *Expression) (*ExprNode, error) return res, nil } +// SecurityFilterSQL compiles the security policy's query filter and row filter for the given metrics view +// into a SQL expression and args that can be used in a WHERE clause against the metrics view's underlying table. +// It returns an empty SQL string if the security policy does not restrict row access. +// It is intended for queries that are built manually instead of through an AST, such as dimension summaries. +func SecurityFilterSQL(mv *runtimev1.MetricsViewSpec, sec MetricsViewSecurity, dialect drivers.Dialect) (string, []any, error) { + a := &AST{ + MetricsView: mv, + Security: sec, + Query: &Query{}, + Dialect: dialect, + } + + res, err := a.buildWhereForUnderlyingTable(nil) + if err != nil { + return "", nil, err + } + if res == nil { + return "", nil, nil + } + return res.Expr, res.Args, nil +} + // buildBaseSelect constructs a base SELECT node against the underlying table. func (a *AST) buildBaseSelect(alias string, comparison bool) (*SelectNode, error) { n := &SelectNode{ diff --git a/runtime/metricsview/executor/executor.go b/runtime/metricsview/executor/executor.go index c7ec6c26c21c..1c2c90b720ab 100644 --- a/runtime/metricsview/executor/executor.go +++ b/runtime/metricsview/executor/executor.go @@ -197,7 +197,7 @@ func (e *Executor) Timestamps(ctx context.Context, timeDim string) (metricsview. return metricsview.TimestampsResult{}, fmt.Errorf(`failed to resolve "data_time_range": %w`, err) } } else { - res, err = e.resolveTimestampsForTable(ctx, mv.Database, mv.DatabaseSchema, mv.Table, timeExpr, mv.WatermarkExpression) + res, err = e.resolveTimestampsForTable(ctx, mv.Database, mv.DatabaseSchema, mv.Table, timeExpr, mv.WatermarkExpression, "", nil) if err != nil { return metricsview.TimestampsResult{}, err } @@ -217,7 +217,7 @@ func (e *Executor) Timestamps(ctx context.Context, timeDim string) (metricsview. res.Rollups[rollup.Table] = rts continue } - rts, err := e.resolveTimestampsForTable(ctx, rollup.Database, rollup.DatabaseSchema, rollup.Table, timeExpr, "") + rts, err := e.resolveTimestampsForTable(ctx, rollup.Database, rollup.DatabaseSchema, rollup.Table, timeExpr, "", "", nil) if err != nil { return metricsview.TimestampsResult{}, fmt.Errorf("failed to resolve timestamps for rollup %q: %w", rollup.Table, err) } diff --git a/runtime/metricsview/executor/executor_summary.go b/runtime/metricsview/executor/executor_summary.go index 6a43eec1f865..66ab7d205634 100644 --- a/runtime/metricsview/executor/executor_summary.go +++ b/runtime/metricsview/executor/executor_summary.go @@ -9,6 +9,7 @@ import ( runtimev1 "github.com/rilldata/rill/proto/gen/rill/runtime/v1" "github.com/rilldata/rill/runtime" "github.com/rilldata/rill/runtime/drivers" + "github.com/rilldata/rill/runtime/metricsview" ) const ( @@ -42,6 +43,12 @@ func (e *Executor) Summary(ctx context.Context) (*SummaryResult, error) { return nil, runtime.ErrForbidden } + // Compile the security policy's row filter and query filter so the summary only reflects rows the user can access + securityFilter, securityFilterArgs, err := metricsview.SecurityFilterSQL(e.metricsView, e.security, e.olap.Dialect()) + if err != nil { + return nil, fmt.Errorf("failed to compile the security policy's filters: %w", err) + } + // Gather the categorical and time dimensions var dimensions, timeDimensions []*runtimev1.MetricsViewSpec_Dimension for _, dim := range e.metricsView.Dimensions { @@ -89,7 +96,7 @@ func (e *Executor) Summary(ctx context.Context) (*SummaryResult, error) { var summaries []DimensionSummary var defaultTimeDimensionSummary DimensionSummary for _, dim := range timeDimensions { - timeRange, err := e.Timestamps(ctx, dim.Name) + timeRange, err := e.summaryTimestamps(ctx, dim.Name, securityFilter, securityFilterArgs) if err != nil { return nil, fmt.Errorf("failed to get time range for dimension %q: %w", dim.Name, err) } @@ -149,16 +156,20 @@ func (e *Executor) Summary(ctx context.Context) (*SummaryResult, error) { } } - // Create a where clause that applies the SummarySampleInterval to the default time dimension - var whereClause string + // Create a where clause that applies the SummarySampleInterval to the default time dimension and the security policy's filters + var whereClauses []string var args []any if timeDimExpr != "" && defaultTimeDimensionSummary.MaxValue != nil { maxTime, _ := defaultTimeDimensionSummary.MaxValue.(time.Time) if !maxTime.IsZero() { - whereClause = fmt.Sprintf("WHERE %s >= ?", timeDimExpr) - args = []any{maxTime.Add(-SummarySampleInterval)} + whereClauses = append(whereClauses, fmt.Sprintf("%s >= ?", timeDimExpr)) + args = append(args, maxTime.Add(-SummarySampleInterval)) } } + if securityFilter != "" { + whereClauses = append(whereClauses, fmt.Sprintf("(%s)", securityFilter)) + args = append(args, securityFilterArgs...) + } // Build the SQL query var sqlBuilder strings.Builder @@ -166,9 +177,9 @@ func (e *Executor) Summary(ctx context.Context) (*SummaryResult, error) { sqlBuilder.WriteString(strings.Join(selectClauses, ", ")) sqlBuilder.WriteString(" FROM ") sqlBuilder.WriteString(e.olap.Dialect().EscapeTable(e.metricsView.Database, e.metricsView.DatabaseSchema, e.metricsView.Table)) - if whereClause != "" { - sqlBuilder.WriteString(" ") - sqlBuilder.WriteString(whereClause) + if len(whereClauses) > 0 { + sqlBuilder.WriteString(" WHERE ") + sqlBuilder.WriteString(strings.Join(whereClauses, " AND ")) } sqlBuilder.WriteString(" LIMIT 1") sql := sqlBuilder.String() @@ -230,3 +241,29 @@ func (e *Executor) Summary(ctx context.Context) (*SummaryResult, error) { DefaultTimeDimension: defaultTimeDimensionSummary, }, nil } + +// summaryTimestamps resolves the time range for a time dimension, applying the compiled security filter when one is present. +// Without a security filter it delegates to Timestamps; +// with one it mirrors the resolution logic of Timestamps, minus rollups (not used by the summary) +// and minus the executor-level cache (which doesn't account for security filters). +func (e *Executor) summaryTimestamps(ctx context.Context, timeDim, securityFilter string, securityFilterArgs []any) (metricsview.TimestampsResult, error) { + if securityFilter == "" { + return e.Timestamps(ctx, timeDim) + } + + timeExpr, err := e.timeColumnOrExpr(timeDim) + if err != nil { + return metricsview.TimestampsResult{}, fmt.Errorf("failed to resolve time column or expression: %w", err) + } + + if timeDim == e.metricsView.TimeDimension && e.metricsView.DataTimeRange != "" { + // The declared data_time_range is metadata, not derived from restricted rows, so no filter is needed. + res, err := e.resolveDeclaredTimestamps(e.metricsView.DataTimeRange) + if err != nil { + return metricsview.TimestampsResult{}, fmt.Errorf(`failed to resolve "data_time_range": %w`, err) + } + return res, nil + } + + return e.resolveTimestampsForTable(ctx, e.metricsView.Database, e.metricsView.DatabaseSchema, e.metricsView.Table, timeExpr, e.metricsView.WatermarkExpression, securityFilter, securityFilterArgs) +} diff --git a/runtime/metricsview/executor/executor_timestamps.go b/runtime/metricsview/executor/executor_timestamps.go index a84e9e9af006..d4bd9dc49ae4 100644 --- a/runtime/metricsview/executor/executor_timestamps.go +++ b/runtime/metricsview/executor/executor_timestamps.go @@ -16,24 +16,25 @@ const ( ) // resolveTimestampsForTable dispatches to the appropriate dialect-specific method. -func (e *Executor) resolveTimestampsForTable(ctx context.Context, database, databaseSchema, table, timeExpr, watermarkExpr string) (metricsview.TimestampsResult, error) { +// If filterExpr is non-empty, it is applied as a WHERE clause (with filterArgs as its query args). +func (e *Executor) resolveTimestampsForTable(ctx context.Context, database, databaseSchema, table, timeExpr, watermarkExpr, filterExpr string, filterArgs []any) (metricsview.TimestampsResult, error) { switch e.olap.Dialect().String() { case drivers.DialectNameDuckDB, drivers.DialectNameSnowflake, drivers.DialectNameDatabricks, drivers.DialectNameStarRocks: - return e.resolveWithTimestampQuery(ctx, database, databaseSchema, table, timeExpr, watermarkExpr) + return e.resolveWithTimestampQuery(ctx, database, databaseSchema, table, timeExpr, watermarkExpr, filterExpr, filterArgs) case drivers.DialectNameClickHouse: - return e.resolveClickHouse(ctx, database, databaseSchema, table, timeExpr, watermarkExpr) + return e.resolveClickHouse(ctx, database, databaseSchema, table, timeExpr, watermarkExpr, filterExpr, filterArgs) case drivers.DialectNamePinot: - return e.resolvePinot(ctx, database, databaseSchema, table, timeExpr, watermarkExpr) + return e.resolvePinot(ctx, database, databaseSchema, table, timeExpr, watermarkExpr, filterExpr, filterArgs) case drivers.DialectNameDruid: - return e.resolveDruid(ctx, database, databaseSchema, table, timeExpr, watermarkExpr) + return e.resolveDruid(ctx, database, databaseSchema, table, timeExpr, watermarkExpr, filterExpr, filterArgs) case drivers.DialectNameBigQuery: - return e.resolveBigQuery(ctx, database, databaseSchema, table, timeExpr, watermarkExpr) + return e.resolveBigQuery(ctx, database, databaseSchema, table, timeExpr, watermarkExpr, filterExpr, filterArgs) default: return metricsview.TimestampsResult{}, fmt.Errorf("not available for dialect '%s'", e.olap.Dialect()) } } -func (e *Executor) resolveWithTimestampQuery(ctx context.Context, database, databaseSchema, table, timeExpr, watermarkExpr string) (metricsview.TimestampsResult, error) { +func (e *Executor) resolveWithTimestampQuery(ctx context.Context, database, databaseSchema, table, timeExpr, watermarkExpr, filterExpr string, filterArgs []any) (metricsview.TimestampsResult, error) { d := e.olap.Dialect() escapedTableName := d.EscapeTable(database, databaseSchema, table) if watermarkExpr == "" { @@ -49,9 +50,13 @@ func (e *Executor) resolveWithTimestampQuery(ctx context.Context, database, data d.EscapeAlias("max"), d.EscapeAlias("watermark"), ) + if filterExpr != "" { + rangeSQL += " WHERE " + filterExpr + } rows, err := e.olap.Query(ctx, &drivers.Statement{ Query: rangeSQL, + Args: filterArgs, Priority: e.priority, ExecutionTimeout: defaultExecutionTimeout, QueryAttributes: e.queryAttributes, @@ -82,7 +87,7 @@ func (e *Executor) resolveWithTimestampQuery(ctx context.Context, database, data return metricsview.TimestampsResult{}, errors.New("no rows returned") } -func (e *Executor) resolveClickHouse(ctx context.Context, database, databaseSchema, table, timeExpr, watermarkExpr string) (metricsview.TimestampsResult, error) { +func (e *Executor) resolveClickHouse(ctx context.Context, database, databaseSchema, table, timeExpr, watermarkExpr, filterExpr string, filterArgs []any) (metricsview.TimestampsResult, error) { escapedTableName := e.olap.Dialect().EscapeTable(database, databaseSchema, table) if watermarkExpr == "" { watermarkExpr = fmt.Sprintf("max(%s)", timeExpr) @@ -94,9 +99,13 @@ func (e *Executor) resolveClickHouse(ctx context.Context, database, databaseSche watermarkExpr, escapedTableName, ) + if filterExpr != "" { + rangeSQL += " WHERE " + filterExpr + } rows, err := e.olap.Query(ctx, &drivers.Statement{ Query: rangeSQL, + Args: filterArgs, Priority: e.priority, ExecutionTimeout: defaultExecutionTimeout, QueryAttributes: e.queryAttributes, @@ -133,7 +142,7 @@ func (e *Executor) resolveClickHouse(ctx context.Context, database, databaseSche return metricsview.TimestampsResult{}, errors.New("no rows returned") } -func (e *Executor) resolvePinot(ctx context.Context, database, databaseSchema, table, timeExpr, watermarkExpr string) (metricsview.TimestampsResult, error) { +func (e *Executor) resolvePinot(ctx context.Context, database, databaseSchema, table, timeExpr, watermarkExpr, filterExpr string, filterArgs []any) (metricsview.TimestampsResult, error) { escapedTableName := e.olap.Dialect().EscapeTable(database, databaseSchema, table) if watermarkExpr == "" { watermarkExpr = fmt.Sprintf("max(%s)", timeExpr) @@ -145,9 +154,13 @@ func (e *Executor) resolvePinot(ctx context.Context, database, databaseSchema, t watermarkExpr, escapedTableName, ) + if filterExpr != "" { + rangeSQL += " WHERE " + filterExpr + } rows, err := e.olap.Query(ctx, &drivers.Statement{ Query: rangeSQL, + Args: filterArgs, Priority: e.priority, ExecutionTimeout: defaultExecutionTimeout, QueryAttributes: e.queryAttributes, @@ -188,8 +201,12 @@ func (e *Executor) resolvePinot(ctx context.Context, database, databaseSchema, t return metricsview.TimestampsResult{}, errors.New("no rows returned") } -func (e *Executor) resolveDruid(ctx context.Context, database, databaseSchema, table, timeExpr, watermarkExpr string) (metricsview.TimestampsResult, error) { +func (e *Executor) resolveDruid(ctx context.Context, database, databaseSchema, table, timeExpr, watermarkExpr, filterExpr string, filterArgs []any) (metricsview.TimestampsResult, error) { escapedTableName := e.olap.Dialect().EscapeTable(database, databaseSchema, table) + var whereClause string + if filterExpr != "" { + whereClause = " WHERE " + filterExpr + } var ts metricsview.TimestampsResult group, ctx := errgroup.WithContext(ctx) @@ -200,13 +217,15 @@ func (e *Executor) resolveDruid(ctx context.Context, database, databaseSchema, t group.Go(func() error { minSQL := fmt.Sprintf( - "SELECT min(%[1]s) as \"min\" FROM %[2]s", + "SELECT min(%[1]s) as \"min\" FROM %[2]s%[3]s", timeExpr, escapedTableName, + whereClause, ) rows, err := e.olap.Query(ctx, &drivers.Statement{ Query: minSQL, + Args: filterArgs, Priority: e.priority, ExecutionTimeout: defaultExecutionTimeout, UseCache: &useCache, @@ -236,13 +255,15 @@ func (e *Executor) resolveDruid(ctx context.Context, database, databaseSchema, t group.Go(func() error { maxSQL := fmt.Sprintf( - "SELECT max(%[1]s) as \"max\" FROM %[2]s", + "SELECT max(%[1]s) as \"max\" FROM %[2]s%[3]s", timeExpr, escapedTableName, + whereClause, ) rows, err := e.olap.Query(ctx, &drivers.Statement{ Query: maxSQL, + Args: filterArgs, Priority: e.priority, ExecutionTimeout: defaultExecutionTimeout, UseCache: &useCache, @@ -272,13 +293,15 @@ func (e *Executor) resolveDruid(ctx context.Context, database, databaseSchema, t if watermarkExpr != "" { group.Go(func() error { maxSQL := fmt.Sprintf( - "SELECT %[1]s as \"watermark\" FROM %[2]s", + "SELECT %[1]s as \"watermark\" FROM %[2]s%[3]s", watermarkExpr, escapedTableName, + whereClause, ) rows, err := e.olap.Query(ctx, &drivers.Statement{ Query: maxSQL, + Args: filterArgs, Priority: e.priority, ExecutionTimeout: defaultExecutionTimeout, UseCache: &useCache, @@ -319,7 +342,7 @@ func (e *Executor) resolveDruid(ctx context.Context, database, databaseSchema, t return ts, nil } -func (e *Executor) resolveBigQuery(ctx context.Context, database, databaseSchema, table, timeExpr, watermarkExpr string) (metricsview.TimestampsResult, error) { +func (e *Executor) resolveBigQuery(ctx context.Context, database, databaseSchema, table, timeExpr, watermarkExpr, filterExpr string, filterArgs []any) (metricsview.TimestampsResult, error) { escapedTableName := e.olap.Dialect().EscapeTable(database, databaseSchema, table) if watermarkExpr == "" { watermarkExpr = fmt.Sprintf("max(%s)", timeExpr) @@ -332,9 +355,13 @@ func (e *Executor) resolveBigQuery(ctx context.Context, database, databaseSchema watermarkExpr, escapedTableName, ) + if filterExpr != "" { + rangeSQL += " WHERE " + filterExpr + } rows, err := e.olap.Query(ctx, &drivers.Statement{ Query: rangeSQL, + Args: filterArgs, Priority: e.priority, ExecutionTimeout: defaultExecutionTimeout, QueryAttributes: e.queryAttributes, diff --git a/runtime/resolver.go b/runtime/resolver.go index 6925aea2238b..d0c589b59568 100644 --- a/runtime/resolver.go +++ b/runtime/resolver.go @@ -18,6 +18,7 @@ import ( "github.com/rilldata/rill/runtime/pkg/observability" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" + "google.golang.org/protobuf/proto" ) var ErrMetricsViewCachingDisabled = errors.New("metrics_cache_key: caching is disabled") @@ -216,6 +217,25 @@ func (r *Runtime) Resolve(ctx context.Context, opts *ResolveOptions) (res Resolv return nil, nil, err } } + // The claims' additional rules also affect results (e.g. row filters from magic auth tokens), so they must be part of the cache key. + for _, rule := range opts.Claims.AdditionalRules { + data, err := proto.MarshalOptions{Deterministic: true}.Marshal(rule) + if err != nil { + return nil, nil, err + } + // Length-prefix each rule so different rule lists can't produce the same byte stream. + if err := binary.Write(hash, binary.BigEndian, uint64(len(data))); err != nil { + return nil, nil, err + } + if _, err := hash.Write(data); err != nil { + return nil, nil, err + } + } + if opts.Claims.SkipChecks { + if _, err := hash.Write([]byte("skip_checks")); err != nil { + return nil, nil, err + } + } for _, ref := range resolver.Refs() { res, err := ctrl.Get(ctx, ref, false) if err != nil { diff --git a/runtime/resolvers/testdata/metrics_summary.yaml b/runtime/resolvers/testdata/metrics_summary.yaml index 6a8e29faecbb..f28cdff24a25 100644 --- a/runtime/resolvers/testdata/metrics_summary.yaml +++ b/runtime/resolvers/testdata/metrics_summary.yaml @@ -66,6 +66,21 @@ project_files: - if: "'{{ .user.role }}' != 'admin'" names: - country + clickhouse_metrics_restricted.yaml: + type: metrics_view + model: clickhouse_test_data + timeseries: event_time + dimensions: + - name: category + column: category + - name: country + column: country + measures: + - name: total_revenue + expression: sum(revenue) + security: + access: true + row_filter: "country = '{{ .user.country }}'" duckdb_metrics_view.yaml: type: metrics_view model: duckdb_test_data @@ -219,17 +234,110 @@ tests: result: - dimensions: - data_type: CODE_TIMESTAMP - max_value: "2024-01-08T00:00:00Z" + max_value: "2024-01-06T00:00:00Z" min_value: "2024-01-01T00:00:00Z" name: event_time + - data_type: CODE_STRING + example_value: Home + max_value: Home + min_value: Home + name: category + time_range: + data_type: CODE_TIMESTAMP + max_value: "2024-01-06T00:00:00Z" + min_value: "2024-01-01T00:00:00Z" + name: event_time + - name: duckdb_summary_with_security_admin + resolver: metrics_summary + properties: + metrics_view: duckdb_metrics_restricted + user_attributes: + country: "CA" + role: "admin" + result: + - dimensions: + - data_type: CODE_TIMESTAMP + max_value: "2024-01-07T00:00:00Z" + min_value: "2024-01-02T00:00:00Z" + name: event_time - data_type: CODE_STRING example_value: Electronics max_value: Electronics - min_value: Clothing + min_value: Electronics name: category + - data_type: CODE_STRING + example_value: CA + max_value: CA + min_value: CA + name: country time_range: data_type: CODE_TIMESTAMP - max_value: "2024-01-08T00:00:00Z" + max_value: "2024-01-07T00:00:00Z" + min_value: "2024-01-02T00:00:00Z" + name: event_time + - name: duckdb_summary_with_additional_row_filter + resolver: metrics_summary + properties: + metrics_view: duckdb_metrics_view + additional_rules: + - row_filter: "country = 'CA'" + result: + - dimensions: + - data_type: CODE_TIMESTAMP + max_value: "2024-01-07T00:00:00Z" + min_value: "2024-01-02T00:00:00Z" + name: event_time + - data_type: CODE_STRING + example_value: CA + max_value: CA + min_value: CA + name: country + - data_type: CODE_STRING + example_value: Electronics + max_value: Electronics + min_value: Electronics + name: category + - data_type: CODE_STRING + has_nulls: true + name: product + - data_type: CODE_STRING + has_nulls: true + name: channel + - data_type: CODE_BOOL + example_value: true + max_value: true + min_value: true + name: is_active + time_range: + data_type: CODE_TIMESTAMP + max_value: "2024-01-07T00:00:00Z" + min_value: "2024-01-02T00:00:00Z" + name: event_time + - name: clickhouse_summary_with_security + resolver: metrics_summary + properties: + metrics_view: clickhouse_metrics_restricted + user_attributes: + country: "US" + result: + - dimensions: + - data_type: CODE_TIMESTAMP + max_value: "2024-01-06T00:00:00Z" + min_value: "2024-01-01T00:00:00Z" + name: event_time + - data_type: CODE_STRING + example_value: Home + max_value: Home + min_value: Home + name: category + - data_type: CODE_STRING + example_value: US + max_value: US + min_value: US + name: country + time_range: + data_type: CODE_TIMESTAMP + max_value: "2024-01-06T00:00:00Z" min_value: "2024-01-01T00:00:00Z" name: event_time - name: duckdb_empty_summary From e94b2c6a019e10d134221b8d1d02cf7e2feb67ae Mon Sep 17 00:00:00 2001 From: Nishant Bangarwa Date: Mon, 20 Jul 2026 21:55:34 +0530 Subject: [PATCH 2/2] 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. --- runtime/metricsview/ast.go | 71 ++++++++++--------- runtime/metricsview/executor/executor.go | 8 ++- .../metricsview/executor/executor_summary.go | 34 ++------- .../executor/executor_timestamps.go | 55 ++++---------- runtime/resolver.go | 33 +++++---- .../resolvers/testdata/metrics_summary.yaml | 43 ++++++----- 6 files changed, 103 insertions(+), 141 deletions(-) diff --git a/runtime/metricsview/ast.go b/runtime/metricsview/ast.go index 09ae95a13cb8..6a4cc71e2e50 100644 --- a/runtime/metricsview/ast.go +++ b/runtime/metricsview/ast.go @@ -363,6 +363,38 @@ func NewAST(mv *runtimev1.MetricsViewSpec, sec MetricsViewSecurity, qry *Query, return ast, nil } +// SecurityFilterSQL compiles the security policy's query filter and row filter for the given metrics view +// into a SQL expression and args that can be used in a WHERE clause against the metrics view's underlying table. +// It returns an empty SQL string if the security policy does not restrict row access. +// It is used when building an AST, and can also be used directly for queries that are built manually, such as dimension summaries. +func SecurityFilterSQL(mv *runtimev1.MetricsViewSpec, sec MetricsViewSecurity, dialect drivers.Dialect) (string, []any, error) { + var res *ExprNode + + if qf := sec.QueryFilter(); qf != nil { + // Compiling an expression requires an AST value for contextual info such as dimension lookups. + a := &AST{ + MetricsView: mv, + Security: sec, + Query: &Query{}, + Dialect: dialect, + } + expr, args, err := a.SQLForExpression(NewExpressionFromProto(qf), nil, false, false) + if err != nil { + return "", nil, fmt.Errorf("failed to compile the security policy's query filter: %w", err) + } + res = res.And(expr, args) + } + + if rf := sec.RowFilter(); rf != "" { + res = res.And(rf, nil) + } + + if res == nil { + return "", nil, nil + } + return res.Expr, res.Args, nil +} + // ResolveDimension returns a dimension spec for the given dimension query. // If the dimension query specifies a computed dimension, it constructs a dimension spec to match it. func (a *AST) ResolveDimension(qd Dimension, visible bool) (*runtimev1.MetricsViewSpec_Dimension, error) { @@ -1022,7 +1054,7 @@ func (a *AST) addReferencedMeasuresToScope(n *SelectNode, referencedMeasures []s } // buildWhereForUnderlyingTable constructs an expression for a WHERE clause for the underlying table. -// It combines the provided where expression with any security policy filters. +// It combines the provided where expression with the security policy's filters. // It allows the input `where` to be nil, and returns nil if there are no conditions to apply. func (a *AST) buildWhereForUnderlyingTable(where *Expression) (*ExprNode, error) { var res *ExprNode @@ -1033,44 +1065,15 @@ func (a *AST) buildWhereForUnderlyingTable(where *Expression) (*ExprNode, error) } res = res.And(expr, args) - if qf := a.Security.QueryFilter(); qf != nil { - e := NewExpressionFromProto(qf) - expr, args, err = a.SQLForExpression(e, nil, false, false) - if err != nil { - return nil, fmt.Errorf("failed to compile the security policy's query filter: %w", err) - } - res = res.And(expr, args) - } - - if rf := a.Security.RowFilter(); rf != "" { - res = res.And(rf, nil) + secExpr, secArgs, err := SecurityFilterSQL(a.MetricsView, a.Security, a.Dialect) + if err != nil { + return nil, err } + res = res.And(secExpr, secArgs) return res, nil } -// SecurityFilterSQL compiles the security policy's query filter and row filter for the given metrics view -// into a SQL expression and args that can be used in a WHERE clause against the metrics view's underlying table. -// It returns an empty SQL string if the security policy does not restrict row access. -// It is intended for queries that are built manually instead of through an AST, such as dimension summaries. -func SecurityFilterSQL(mv *runtimev1.MetricsViewSpec, sec MetricsViewSecurity, dialect drivers.Dialect) (string, []any, error) { - a := &AST{ - MetricsView: mv, - Security: sec, - Query: &Query{}, - Dialect: dialect, - } - - res, err := a.buildWhereForUnderlyingTable(nil) - if err != nil { - return "", nil, err - } - if res == nil { - return "", nil, nil - } - return res.Expr, res.Args, nil -} - // buildBaseSelect constructs a base SELECT node against the underlying table. func (a *AST) buildBaseSelect(alias string, comparison bool) (*SelectNode, error) { n := &SelectNode{ diff --git a/runtime/metricsview/executor/executor.go b/runtime/metricsview/executor/executor.go index 1c2c90b720ab..79f46d3cf83a 100644 --- a/runtime/metricsview/executor/executor.go +++ b/runtime/metricsview/executor/executor.go @@ -170,6 +170,10 @@ func (e *Executor) ValidateQuery(qry *metricsview.Query) error { // Timestamps queries min, max and watermark for the metrics view. // For the primary time dimension it also resolves rollup table timestamps if rollups are present. +// It intentionally does not apply security policy row filters: +// unfiltered timestamps can be computed from database metadata and cached across users, +// and they keep time expressions evaluating consistently for all users. +// The trade-off is that users whose accessible rows don't span the full range may see "no data" for some time ranges. func (e *Executor) Timestamps(ctx context.Context, timeDim string) (metricsview.TimestampsResult, error) { if timeDim == "" { timeDim = e.metricsView.TimeDimension @@ -197,7 +201,7 @@ func (e *Executor) Timestamps(ctx context.Context, timeDim string) (metricsview. return metricsview.TimestampsResult{}, fmt.Errorf(`failed to resolve "data_time_range": %w`, err) } } else { - res, err = e.resolveTimestampsForTable(ctx, mv.Database, mv.DatabaseSchema, mv.Table, timeExpr, mv.WatermarkExpression, "", nil) + res, err = e.resolveTimestampsForTable(ctx, mv.Database, mv.DatabaseSchema, mv.Table, timeExpr, mv.WatermarkExpression) if err != nil { return metricsview.TimestampsResult{}, err } @@ -217,7 +221,7 @@ func (e *Executor) Timestamps(ctx context.Context, timeDim string) (metricsview. res.Rollups[rollup.Table] = rts continue } - rts, err := e.resolveTimestampsForTable(ctx, rollup.Database, rollup.DatabaseSchema, rollup.Table, timeExpr, "", "", nil) + rts, err := e.resolveTimestampsForTable(ctx, rollup.Database, rollup.DatabaseSchema, rollup.Table, timeExpr, "") if err != nil { return metricsview.TimestampsResult{}, fmt.Errorf("failed to resolve timestamps for rollup %q: %w", rollup.Table, err) } diff --git a/runtime/metricsview/executor/executor_summary.go b/runtime/metricsview/executor/executor_summary.go index 66ab7d205634..96d4491b9677 100644 --- a/runtime/metricsview/executor/executor_summary.go +++ b/runtime/metricsview/executor/executor_summary.go @@ -92,11 +92,13 @@ func (e *Executor) Summary(ctx context.Context) (*SummaryResult, error) { timeDimensions = timeDimensions[0:SummaryTimeDimensionsLimit] } - // Compute the time dimension summaries + // Compute the time dimension summaries. + // Note that Timestamps intentionally does not apply the security policy's filters (see its docstring), + // so unlike the dimension summaries below, the time ranges reflect all rows in the underlying table. var summaries []DimensionSummary var defaultTimeDimensionSummary DimensionSummary for _, dim := range timeDimensions { - timeRange, err := e.summaryTimestamps(ctx, dim.Name, securityFilter, securityFilterArgs) + timeRange, err := e.Timestamps(ctx, dim.Name) if err != nil { return nil, fmt.Errorf("failed to get time range for dimension %q: %w", dim.Name, err) } @@ -166,6 +168,8 @@ func (e *Executor) Summary(ctx context.Context) (*SummaryResult, error) { args = append(args, maxTime.Add(-SummarySampleInterval)) } } + // Note the sample interval is derived from the unfiltered max timestamp, + // so if the user's accessible rows are all older than the sample interval, the summary values will be null. if securityFilter != "" { whereClauses = append(whereClauses, fmt.Sprintf("(%s)", securityFilter)) args = append(args, securityFilterArgs...) @@ -241,29 +245,3 @@ func (e *Executor) Summary(ctx context.Context) (*SummaryResult, error) { DefaultTimeDimension: defaultTimeDimensionSummary, }, nil } - -// summaryTimestamps resolves the time range for a time dimension, applying the compiled security filter when one is present. -// Without a security filter it delegates to Timestamps; -// with one it mirrors the resolution logic of Timestamps, minus rollups (not used by the summary) -// and minus the executor-level cache (which doesn't account for security filters). -func (e *Executor) summaryTimestamps(ctx context.Context, timeDim, securityFilter string, securityFilterArgs []any) (metricsview.TimestampsResult, error) { - if securityFilter == "" { - return e.Timestamps(ctx, timeDim) - } - - timeExpr, err := e.timeColumnOrExpr(timeDim) - if err != nil { - return metricsview.TimestampsResult{}, fmt.Errorf("failed to resolve time column or expression: %w", err) - } - - if timeDim == e.metricsView.TimeDimension && e.metricsView.DataTimeRange != "" { - // The declared data_time_range is metadata, not derived from restricted rows, so no filter is needed. - res, err := e.resolveDeclaredTimestamps(e.metricsView.DataTimeRange) - if err != nil { - return metricsview.TimestampsResult{}, fmt.Errorf(`failed to resolve "data_time_range": %w`, err) - } - return res, nil - } - - return e.resolveTimestampsForTable(ctx, e.metricsView.Database, e.metricsView.DatabaseSchema, e.metricsView.Table, timeExpr, e.metricsView.WatermarkExpression, securityFilter, securityFilterArgs) -} diff --git a/runtime/metricsview/executor/executor_timestamps.go b/runtime/metricsview/executor/executor_timestamps.go index d4bd9dc49ae4..a84e9e9af006 100644 --- a/runtime/metricsview/executor/executor_timestamps.go +++ b/runtime/metricsview/executor/executor_timestamps.go @@ -16,25 +16,24 @@ const ( ) // resolveTimestampsForTable dispatches to the appropriate dialect-specific method. -// If filterExpr is non-empty, it is applied as a WHERE clause (with filterArgs as its query args). -func (e *Executor) resolveTimestampsForTable(ctx context.Context, database, databaseSchema, table, timeExpr, watermarkExpr, filterExpr string, filterArgs []any) (metricsview.TimestampsResult, error) { +func (e *Executor) resolveTimestampsForTable(ctx context.Context, database, databaseSchema, table, timeExpr, watermarkExpr string) (metricsview.TimestampsResult, error) { switch e.olap.Dialect().String() { case drivers.DialectNameDuckDB, drivers.DialectNameSnowflake, drivers.DialectNameDatabricks, drivers.DialectNameStarRocks: - return e.resolveWithTimestampQuery(ctx, database, databaseSchema, table, timeExpr, watermarkExpr, filterExpr, filterArgs) + return e.resolveWithTimestampQuery(ctx, database, databaseSchema, table, timeExpr, watermarkExpr) case drivers.DialectNameClickHouse: - return e.resolveClickHouse(ctx, database, databaseSchema, table, timeExpr, watermarkExpr, filterExpr, filterArgs) + return e.resolveClickHouse(ctx, database, databaseSchema, table, timeExpr, watermarkExpr) case drivers.DialectNamePinot: - return e.resolvePinot(ctx, database, databaseSchema, table, timeExpr, watermarkExpr, filterExpr, filterArgs) + return e.resolvePinot(ctx, database, databaseSchema, table, timeExpr, watermarkExpr) case drivers.DialectNameDruid: - return e.resolveDruid(ctx, database, databaseSchema, table, timeExpr, watermarkExpr, filterExpr, filterArgs) + return e.resolveDruid(ctx, database, databaseSchema, table, timeExpr, watermarkExpr) case drivers.DialectNameBigQuery: - return e.resolveBigQuery(ctx, database, databaseSchema, table, timeExpr, watermarkExpr, filterExpr, filterArgs) + return e.resolveBigQuery(ctx, database, databaseSchema, table, timeExpr, watermarkExpr) default: return metricsview.TimestampsResult{}, fmt.Errorf("not available for dialect '%s'", e.olap.Dialect()) } } -func (e *Executor) resolveWithTimestampQuery(ctx context.Context, database, databaseSchema, table, timeExpr, watermarkExpr, filterExpr string, filterArgs []any) (metricsview.TimestampsResult, error) { +func (e *Executor) resolveWithTimestampQuery(ctx context.Context, database, databaseSchema, table, timeExpr, watermarkExpr string) (metricsview.TimestampsResult, error) { d := e.olap.Dialect() escapedTableName := d.EscapeTable(database, databaseSchema, table) if watermarkExpr == "" { @@ -50,13 +49,9 @@ func (e *Executor) resolveWithTimestampQuery(ctx context.Context, database, data d.EscapeAlias("max"), d.EscapeAlias("watermark"), ) - if filterExpr != "" { - rangeSQL += " WHERE " + filterExpr - } rows, err := e.olap.Query(ctx, &drivers.Statement{ Query: rangeSQL, - Args: filterArgs, Priority: e.priority, ExecutionTimeout: defaultExecutionTimeout, QueryAttributes: e.queryAttributes, @@ -87,7 +82,7 @@ func (e *Executor) resolveWithTimestampQuery(ctx context.Context, database, data return metricsview.TimestampsResult{}, errors.New("no rows returned") } -func (e *Executor) resolveClickHouse(ctx context.Context, database, databaseSchema, table, timeExpr, watermarkExpr, filterExpr string, filterArgs []any) (metricsview.TimestampsResult, error) { +func (e *Executor) resolveClickHouse(ctx context.Context, database, databaseSchema, table, timeExpr, watermarkExpr string) (metricsview.TimestampsResult, error) { escapedTableName := e.olap.Dialect().EscapeTable(database, databaseSchema, table) if watermarkExpr == "" { watermarkExpr = fmt.Sprintf("max(%s)", timeExpr) @@ -99,13 +94,9 @@ func (e *Executor) resolveClickHouse(ctx context.Context, database, databaseSche watermarkExpr, escapedTableName, ) - if filterExpr != "" { - rangeSQL += " WHERE " + filterExpr - } rows, err := e.olap.Query(ctx, &drivers.Statement{ Query: rangeSQL, - Args: filterArgs, Priority: e.priority, ExecutionTimeout: defaultExecutionTimeout, QueryAttributes: e.queryAttributes, @@ -142,7 +133,7 @@ func (e *Executor) resolveClickHouse(ctx context.Context, database, databaseSche return metricsview.TimestampsResult{}, errors.New("no rows returned") } -func (e *Executor) resolvePinot(ctx context.Context, database, databaseSchema, table, timeExpr, watermarkExpr, filterExpr string, filterArgs []any) (metricsview.TimestampsResult, error) { +func (e *Executor) resolvePinot(ctx context.Context, database, databaseSchema, table, timeExpr, watermarkExpr string) (metricsview.TimestampsResult, error) { escapedTableName := e.olap.Dialect().EscapeTable(database, databaseSchema, table) if watermarkExpr == "" { watermarkExpr = fmt.Sprintf("max(%s)", timeExpr) @@ -154,13 +145,9 @@ func (e *Executor) resolvePinot(ctx context.Context, database, databaseSchema, t watermarkExpr, escapedTableName, ) - if filterExpr != "" { - rangeSQL += " WHERE " + filterExpr - } rows, err := e.olap.Query(ctx, &drivers.Statement{ Query: rangeSQL, - Args: filterArgs, Priority: e.priority, ExecutionTimeout: defaultExecutionTimeout, QueryAttributes: e.queryAttributes, @@ -201,12 +188,8 @@ func (e *Executor) resolvePinot(ctx context.Context, database, databaseSchema, t return metricsview.TimestampsResult{}, errors.New("no rows returned") } -func (e *Executor) resolveDruid(ctx context.Context, database, databaseSchema, table, timeExpr, watermarkExpr, filterExpr string, filterArgs []any) (metricsview.TimestampsResult, error) { +func (e *Executor) resolveDruid(ctx context.Context, database, databaseSchema, table, timeExpr, watermarkExpr string) (metricsview.TimestampsResult, error) { escapedTableName := e.olap.Dialect().EscapeTable(database, databaseSchema, table) - var whereClause string - if filterExpr != "" { - whereClause = " WHERE " + filterExpr - } var ts metricsview.TimestampsResult group, ctx := errgroup.WithContext(ctx) @@ -217,15 +200,13 @@ func (e *Executor) resolveDruid(ctx context.Context, database, databaseSchema, t group.Go(func() error { minSQL := fmt.Sprintf( - "SELECT min(%[1]s) as \"min\" FROM %[2]s%[3]s", + "SELECT min(%[1]s) as \"min\" FROM %[2]s", timeExpr, escapedTableName, - whereClause, ) rows, err := e.olap.Query(ctx, &drivers.Statement{ Query: minSQL, - Args: filterArgs, Priority: e.priority, ExecutionTimeout: defaultExecutionTimeout, UseCache: &useCache, @@ -255,15 +236,13 @@ func (e *Executor) resolveDruid(ctx context.Context, database, databaseSchema, t group.Go(func() error { maxSQL := fmt.Sprintf( - "SELECT max(%[1]s) as \"max\" FROM %[2]s%[3]s", + "SELECT max(%[1]s) as \"max\" FROM %[2]s", timeExpr, escapedTableName, - whereClause, ) rows, err := e.olap.Query(ctx, &drivers.Statement{ Query: maxSQL, - Args: filterArgs, Priority: e.priority, ExecutionTimeout: defaultExecutionTimeout, UseCache: &useCache, @@ -293,15 +272,13 @@ func (e *Executor) resolveDruid(ctx context.Context, database, databaseSchema, t if watermarkExpr != "" { group.Go(func() error { maxSQL := fmt.Sprintf( - "SELECT %[1]s as \"watermark\" FROM %[2]s%[3]s", + "SELECT %[1]s as \"watermark\" FROM %[2]s", watermarkExpr, escapedTableName, - whereClause, ) rows, err := e.olap.Query(ctx, &drivers.Statement{ Query: maxSQL, - Args: filterArgs, Priority: e.priority, ExecutionTimeout: defaultExecutionTimeout, UseCache: &useCache, @@ -342,7 +319,7 @@ func (e *Executor) resolveDruid(ctx context.Context, database, databaseSchema, t return ts, nil } -func (e *Executor) resolveBigQuery(ctx context.Context, database, databaseSchema, table, timeExpr, watermarkExpr, filterExpr string, filterArgs []any) (metricsview.TimestampsResult, error) { +func (e *Executor) resolveBigQuery(ctx context.Context, database, databaseSchema, table, timeExpr, watermarkExpr string) (metricsview.TimestampsResult, error) { escapedTableName := e.olap.Dialect().EscapeTable(database, databaseSchema, table) if watermarkExpr == "" { watermarkExpr = fmt.Sprintf("max(%s)", timeExpr) @@ -355,13 +332,9 @@ func (e *Executor) resolveBigQuery(ctx context.Context, database, databaseSchema watermarkExpr, escapedTableName, ) - if filterExpr != "" { - rangeSQL += " WHERE " + filterExpr - } rows, err := e.olap.Query(ctx, &drivers.Statement{ Query: rangeSQL, - Args: filterArgs, Priority: e.priority, ExecutionTimeout: defaultExecutionTimeout, QueryAttributes: e.queryAttributes, diff --git a/runtime/resolver.go b/runtime/resolver.go index d0c589b59568..54c411896e2b 100644 --- a/runtime/resolver.go +++ b/runtime/resolver.go @@ -208,6 +208,8 @@ func (r *Runtime) Resolve(ctx context.Context, opts *ResolveOptions) (res Resolv if _, err := hash.Write(cacheKey); err != nil { return nil, nil, err } + // NOTE: The user attributes are part of the cache key even when SkipChecks is true + // because they are used for templating (e.g. {{ .user }}), not only for security checks. if opts.Claims.UserAttributes != nil { h, err := hashstructure.Hash(opts.Claims.UserAttributes, hashstructure.FormatV2, nil) if err != nil { @@ -217,24 +219,27 @@ func (r *Runtime) Resolve(ctx context.Context, opts *ResolveOptions) (res Resolv return nil, nil, err } } - // The claims' additional rules also affect results (e.g. row filters from magic auth tokens), so they must be part of the cache key. - for _, rule := range opts.Claims.AdditionalRules { - data, err := proto.MarshalOptions{Deterministic: true}.Marshal(rule) - if err != nil { - return nil, nil, err - } - // Length-prefix each rule so different rule lists can't produce the same byte stream. - if err := binary.Write(hash, binary.BigEndian, uint64(len(data))); err != nil { - return nil, nil, err - } - if _, err := hash.Write(data); err != nil { - return nil, nil, err - } - } if opts.Claims.SkipChecks { + // When security checks are skipped, the security policies and the claims' additional rules are not applied, + // so the rules don't need to be part of the cache key. if _, err := hash.Write([]byte("skip_checks")); err != nil { return nil, nil, err } + } else { + // The claims' additional rules affect results (e.g. row filters from magic auth tokens), so they must be part of the cache key. + for _, rule := range opts.Claims.AdditionalRules { + data, err := proto.MarshalOptions{Deterministic: true}.Marshal(rule) + if err != nil { + return nil, nil, err + } + // Length-prefix each rule so different rule lists can't produce the same byte stream. + if err := binary.Write(hash, binary.BigEndian, uint64(len(data))); err != nil { + return nil, nil, err + } + if _, err := hash.Write(data); err != nil { + return nil, nil, err + } + } } for _, ref := range resolver.Refs() { res, err := ctrl.Get(ctx, ref, false) diff --git a/runtime/resolvers/testdata/metrics_summary.yaml b/runtime/resolvers/testdata/metrics_summary.yaml index f28cdff24a25..ec28ab8496eb 100644 --- a/runtime/resolvers/testdata/metrics_summary.yaml +++ b/runtime/resolvers/testdata/metrics_summary.yaml @@ -224,6 +224,8 @@ tests: max_value: "2024-01-08T00:00:00Z" min_value: "2024-01-01T00:00:00Z" name: event_time + # The time range is intentionally unfiltered (see the Timestamps docstring), and since this user's accessible + # rows are all older than the sample interval, the dimension summary values are null (instead of leaking values). - name: duckdb_summary_with_security resolver: metrics_summary properties: @@ -234,17 +236,14 @@ tests: result: - dimensions: - data_type: CODE_TIMESTAMP - max_value: "2024-01-06T00:00:00Z" + max_value: "2024-01-08T00:00:00Z" min_value: "2024-01-01T00:00:00Z" name: event_time - data_type: CODE_STRING - example_value: Home - max_value: Home - min_value: Home name: category time_range: data_type: CODE_TIMESTAMP - max_value: "2024-01-06T00:00:00Z" + max_value: "2024-01-08T00:00:00Z" min_value: "2024-01-01T00:00:00Z" name: event_time - name: duckdb_summary_with_security_admin @@ -257,8 +256,8 @@ tests: result: - dimensions: - data_type: CODE_TIMESTAMP - max_value: "2024-01-07T00:00:00Z" - min_value: "2024-01-02T00:00:00Z" + max_value: "2024-01-08T00:00:00Z" + min_value: "2024-01-01T00:00:00Z" name: event_time - data_type: CODE_STRING example_value: Electronics @@ -272,8 +271,8 @@ tests: name: country time_range: data_type: CODE_TIMESTAMP - max_value: "2024-01-07T00:00:00Z" - min_value: "2024-01-02T00:00:00Z" + max_value: "2024-01-08T00:00:00Z" + min_value: "2024-01-01T00:00:00Z" name: event_time - name: duckdb_summary_with_additional_row_filter resolver: metrics_summary @@ -284,8 +283,8 @@ tests: result: - dimensions: - data_type: CODE_TIMESTAMP - max_value: "2024-01-07T00:00:00Z" - min_value: "2024-01-02T00:00:00Z" + max_value: "2024-01-08T00:00:00Z" + min_value: "2024-01-01T00:00:00Z" name: event_time - data_type: CODE_STRING example_value: CA @@ -310,34 +309,34 @@ tests: name: is_active time_range: data_type: CODE_TIMESTAMP - max_value: "2024-01-07T00:00:00Z" - min_value: "2024-01-02T00:00:00Z" + max_value: "2024-01-08T00:00:00Z" + min_value: "2024-01-01T00:00:00Z" name: event_time - name: clickhouse_summary_with_security resolver: metrics_summary properties: metrics_view: clickhouse_metrics_restricted user_attributes: - country: "US" + country: "CA" result: - dimensions: - data_type: CODE_TIMESTAMP - max_value: "2024-01-06T00:00:00Z" + max_value: "2024-01-08T00:00:00Z" min_value: "2024-01-01T00:00:00Z" name: event_time - data_type: CODE_STRING - example_value: Home - max_value: Home - min_value: Home + example_value: Electronics + max_value: Electronics + min_value: Electronics name: category - data_type: CODE_STRING - example_value: US - max_value: US - min_value: US + example_value: CA + max_value: CA + min_value: CA name: country time_range: data_type: CODE_TIMESTAMP - max_value: "2024-01-06T00:00:00Z" + max_value: "2024-01-08T00:00:00Z" min_value: "2024-01-01T00:00:00Z" name: event_time - name: duckdb_empty_summary