Skip to content

Apply security row and query filters in metrics view Summary#9717

Merged
begelundmuller merged 3 commits into
mainfrom
nishantmonu51/mv-summary-row-filter
Jul 21, 2026
Merged

Apply security row and query filters in metrics view Summary#9717
begelundmuller merged 3 commits into
mainfrom
nishantmonu51/mv-summary-row-filter

Conversation

@nishantmonu51

@nishantmonu51 nishantmonu51 commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator
  • Executor.Summary built its SQL by hand and never applied the security policy's row filter or query filter, so example_value/min_value/max_value for accessible dimensions were computed over all rows, leaking values across row-level security boundaries (reachable via the metrics_summary resolver and the AI query_metrics_view_summary tool).
  • Adds metricsview.SecurityFilterSQL, which compiles the policy's query filter and row filter into a WHERE expression for the underlying table. It is now the shared implementation used by buildWhereForUnderlyingTable (AST path) and applied to the summary's categorical dimension query.
  • Time ranges intentionally remain unfiltered, consistent with the earlier decision on security for time expressions (metadata-only computation, cross-user caching, consistent time ranges); this is now documented on Timestamps. As a consequence, if a user's accessible rows are all older than the summary's 24h sample window, their summary values are null.
  • Also includes the claims' AdditionalRules in the resolver result cache key (skipped when SkipChecks is true, since security is not applied then). Previously, two magic auth tokens with different row filters but identical user attributes shared cached results (the new additional_rules summary test fails deterministically without this).
  • Updates the metrics_summary resolver test expectations and adds cases for a viewer whose values are null rather than leaked, an admin user, ClickHouse, and an additional-rules (query filter) policy.

Checklist:

  • Covered by tests
  • Ran it and it works as intended
  • Reviewed the diff before requesting a review
  • Checked for unhandled edge cases
  • Linked the issues it closes
  • Checked if the docs need to be updated. If so, create a separate Linear DOCS issue
  • Intend to cherry-pick into the release branch
  • I'm proud of this work!

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.
}
} 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be nice to add a docstring to the function that it intentionally does not apply security policies. The reasons are described here: https://rilldata.slack.com/archives/C01A9DYP013/p1776348750584469

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added, summarizing the reasons from the linked thread.

var defaultTimeDimensionSummary DimensionSummary
for _, dim := range timeDimensions {
timeRange, err := e.Timestamps(ctx, dim.Name)
timeRange, err := e.summaryTimestamps(ctx, dim.Name, securityFilter, securityFilterArgs)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note this changes summary calls (used by MCP) from a pretty cheap operation that only checks metadata and a small time range to potentially a full scan of all time (since there's a filter to handle when computing min/max time).

Not sure if it's worth it (for example, for normal dashboard loads, we don't apply security policies for time ranges to make sure min/max time can be computed as only a metadata operation).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, reverted. Time ranges now come from the unfiltered Timestamps as before; only the categorical dimension query (already bounded by the 24h sample window) gets the security filter. If a user's accessible rows are older than the window, the values come back null, mirroring the accepted trade-off for dashboards.

Comment thread runtime/metricsview/ast.go Outdated
Comment on lines +1052 to +1072
// 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
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. This is pretty hacky usage of buildWhereForUnderlyingTable. I think it would be cleaner to move the security filter logic from buildWhereForUnderlyingTable into this function, and then make buildWhereForUnderlyingTable call this function. That would avoid creating a 'fake' AST value and abusing buildWhereForUnderlyingTable.
  2. This function is placed in a weird place in this file. It breaks the style guide rules about a) grouping by receiver, b) placing exported functions before internal ones. See https://github.com/uber-go/guide/blob/master/style.md#function-grouping-and-ordering

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. buildWhereForUnderlyingTable now calls SecurityFilterSQL, which owns the filter compilation, and it is moved up with the exported functions.

Comment thread runtime/resolver.go Outdated
Comment on lines 234 to 238
if opts.Claims.SkipChecks {
if _, err := hash.Write([]byte("skip_checks")); err != nil {
return nil, nil, err
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When skip_checks is true, it probably should not write any access related fields (like user attributes or additional rules) to the hash.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional rules are now skipped when skip_checks is true. I kept user attributes in the key though: SkipChecks claims carry real attributes in some paths (ManageInstances claims, skip_nested_security APIs) and attributes feed {{ .user }} templating regardless of security checks, so excluding them could serve one user's templated results to another.

- 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.
…ary-row-filter

# Conflicts:
#	runtime/resolver.go
@begelundmuller
begelundmuller merged commit d40f497 into main Jul 21, 2026
11 checks passed
@begelundmuller
begelundmuller deleted the nishantmonu51/mv-summary-row-filter branch July 21, 2026 11:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants