From 2feba96e538c2ac97396f33059be68e12c8adc61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philip=20S=C3=B8rensen?= Date: Thu, 17 Sep 2026 18:37:42 +0200 Subject: [PATCH] fix: aggregate result types respect column-level SELECT permission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `*AggregateResult` object types (Sum/Avg/Min/Max) built by `AggregateNumericType::fields()` listed every aggregatable column on a table without filtering by `column.permissions.is_selectable`, unlike every sibling builder — `FilterEntityType`, `OrderByEntityType`, the Node column builder, `InsertInputType`, `UpdateSetInput` all filter on the relevant permission flag. As a result, a role with table-level SELECT but a column-level `REVOKE SELECT` on a specific column still saw that column's name and type exposed under `{Sum,Avg,Min,Max}AggregateResult` during introspection. This aligns the aggregate builder with the rest of the schema so a non-selectable column is omitted there too. Adds `permissions_aggregate_column` regression test covering the introspection of the aggregate result types under a column-level revoke. Signed-off-by: Philip Sørensen Co-Authored-By: Claude Opus 4.8 --- src/graphql.rs | 3 +- .../expected/permissions_aggregate_column.out | 144 ++++++++++++++++++ test/sql/permissions_aggregate_column.sql | 81 ++++++++++ 3 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 test/expected/permissions_aggregate_column.out create mode 100644 test/sql/permissions_aggregate_column.sql diff --git a/src/graphql.rs b/src/graphql.rs index fb7b4339..b2415eca 100644 --- a/src/graphql.rs +++ b/src/graphql.rs @@ -4754,7 +4754,8 @@ impl ___Type for AggregateNumericType { let mut fields = Vec::new(); for col in self.table.columns.iter() { - if is_aggregatable(col, &self.aggregate_op) + if col.permissions.is_selectable + && is_aggregatable(col, &self.aggregate_op) && let Some(scalar_type) = aggregate_result_type(col, &self.aggregate_op) { let field_name = self.schema.graphql_column_field_name(col); diff --git a/test/expected/permissions_aggregate_column.out b/test/expected/permissions_aggregate_column.out new file mode 100644 index 00000000..7c63451f --- /dev/null +++ b/test/expected/permissions_aggregate_column.out @@ -0,0 +1,144 @@ +begin; + comment on schema public is e'@graphql({"inflect_names": true, "introspection": true})'; + create table account( + id serial primary key, + balance numeric not null, + score int not null + ); + -- Aggregates are opt-in per table. + comment on table account is e'@graphql({"aggregate": {"enabled": true}})'; + insert into public.account(balance, score) + values + (100.00, 5), + (250.00, 9); + -- Superuser sees every aggregatable column, including `balance`. + select jsonb_pretty( + graphql.resolve($$ + { + __type(name: "AccountSumAggregateResult") { + kind + fields { name } + } + } + $$) + ); + jsonb_pretty +--------------------------------------- + { + + "data": { + + "__type": { + + "kind": "OBJECT", + + "fields": [ + + { + + "name": "id" + + }, + + { + + "name": "balance"+ + }, + + { + + "name": "score" + + } + + ] + + } + + } + + } +(1 row) + + create role api; + -- Grant access to GQL + grant usage on schema graphql to api; + -- `api` may read id + score, but NOT balance. + grant usage on schema public to api; + grant all on all tables in schema public to api; + revoke select on public.account from api; + grant select (id, score) on public.account to api; + set role api; + -- The aggregate result types must not expose the non-selectable `balance` + -- column (schema/introspection leak). Only `score` should appear. + select jsonb_pretty( + graphql.resolve($$ + { + __type(name: "AccountSumAggregateResult") { + kind + fields { name } + } + } + $$) + ); + jsonb_pretty +------------------------------------- + { + + "data": { + + "__type": { + + "kind": "OBJECT", + + "fields": [ + + { + + "name": "id" + + }, + + { + + "name": "score"+ + } + + ] + + } + + } + + } +(1 row) + + select jsonb_pretty( + graphql.resolve($$ + { + __type(name: "AccountMaxAggregateResult") { + kind + fields { name } + } + } + $$) + ); + jsonb_pretty +------------------------------------- + { + + "data": { + + "__type": { + + "kind": "OBJECT", + + "fields": [ + + { + + "name": "id" + + }, + + { + + "name": "score"+ + } + + ] + + } + + } + + } +(1 row) + + -- Aggregating a permitted column still works. + select jsonb_pretty( + graphql.resolve($$ + { + accountCollection { + aggregate { + sum { score } + } + } + } + $$) + ); + jsonb_pretty +--------------------------------- + { + + "data": { + + "accountCollection": { + + "aggregate": { + + "sum": { + + "score": 14+ + } + + } + + } + + } + + } +(1 row) + + reset role; +rollback; diff --git a/test/sql/permissions_aggregate_column.sql b/test/sql/permissions_aggregate_column.sql new file mode 100644 index 00000000..96b3d99f --- /dev/null +++ b/test/sql/permissions_aggregate_column.sql @@ -0,0 +1,81 @@ +begin; + comment on schema public is e'@graphql({"inflect_names": true, "introspection": true})'; + + create table account( + id serial primary key, + balance numeric not null, + score int not null + ); + + -- Aggregates are opt-in per table. + comment on table account is e'@graphql({"aggregate": {"enabled": true}})'; + + insert into public.account(balance, score) + values + (100.00, 5), + (250.00, 9); + + -- Superuser sees every aggregatable column, including `balance`. + select jsonb_pretty( + graphql.resolve($$ + { + __type(name: "AccountSumAggregateResult") { + kind + fields { name } + } + } + $$) + ); + + create role api; + + -- Grant access to GQL + grant usage on schema graphql to api; + + -- `api` may read id + score, but NOT balance. + grant usage on schema public to api; + grant all on all tables in schema public to api; + revoke select on public.account from api; + grant select (id, score) on public.account to api; + + set role api; + + -- The aggregate result types must not expose the non-selectable `balance` + -- column (schema/introspection leak). Only `score` should appear. + select jsonb_pretty( + graphql.resolve($$ + { + __type(name: "AccountSumAggregateResult") { + kind + fields { name } + } + } + $$) + ); + + select jsonb_pretty( + graphql.resolve($$ + { + __type(name: "AccountMaxAggregateResult") { + kind + fields { name } + } + } + $$) + ); + + -- Aggregating a permitted column still works. + select jsonb_pretty( + graphql.resolve($$ + { + accountCollection { + aggregate { + sum { score } + } + } + } + $$) + ); + + reset role; +rollback;