Skip to content

fix(api-gateway): return 403 for RBAC member-level denials - #11836

Open
allanmaclean wants to merge 1 commit into
cube-js:masterfrom
allanmaclean:fix/rbac-denial-handling
Open

fix(api-gateway): return 403 for RBAC member-level denials#11836
allanmaclean wants to merge 1 commit into
cube-js:masterfrom
allanmaclean:fix/rbac-denial-handling

Conversation

@allanmaclean

Copy link
Copy Markdown

Check List

  • Tests have been run in packages where changes have been made if available
  • Linter has been run for changed code
  • Tests for the changes have been added if not covered yet
  • Docs have been added / updated if required

Issue Reference this PR resolves

Fixes #11769

Description of Changes Made

A query that an access policy denies member-level access to was still executed. It came back with no rows and then failed while the result was transformed, with You requested hidden member: 'orders_view.status'. Please make it visible using `public: true`..., surfaced to the caller as HTTP 500.

Two problems from the issue: an authorization failure is reported as a server fault, and the guidance tells the operator to set public: true, which would weaken the very policy that did the denying.

CompilerApi.applyRowLevelSecurity already detects the denial before execution — it neutralizes the query with a 1 = 0 segment — so the information needed to answer correctly was there all along, just not propagated. This PR follows the issue's preferred fix (evaluate member access in JS before execution and throw a typed error):

  • applyRowLevelSecurity now returns deniedMembers alongside denied, collecting every member no policy grants rather than bailing on the first one.
  • ApiGateway.getNormalizedQueries propagates those members to its callers.
  • ApiGateway.load — the data-API entry point shared by REST, GraphQL and subscriptions — rejects a denied query with 403 Forbidden and a message naming the denied members: Access to the following members is denied by an access policy: orders_view.status.

On the third point in the issue (the restricted member name being disclosed): the response names the denied members that the request itself asked for. The caller supplied those names, and a member that doesn't exist already fails differently — 400, "'foo' not found for path 'orders.foo'" — so existence is disclosed by the status code either way. Withholding the names would hide nothing while making a denial on a many-membered query hard to act on. Policies are evaluated over the members the generated SQL touches, which pulls in members the caller never named (a cube's primary key, for one); those are filtered out of the message and logged server-side (Access Policy Denied) instead.

Deliberately unchanged:

  • SQL API keeps its empty-result semantics for denials (sqlApiLoad/stream ignore the new flag), so the existing two-dimensional policy-overlap behavior is untouched.
  • Members hidden by public: false outside of RBAC keep the existing message — there the public: true advice is the correct guidance, so the Rust-side check in query_result_transform.rs is left alone.

Dev mode is deliberately left as it was. It doesn't enforce security checks (enforceSecurityChecks is false outside NODE_ENV=production), so a request without a token — the playground's normal state — carries no security context, resolves to no groups, and is therefore denied by every policy. Returning 403 there would break the playground for any model using access policies, so the denial is only logged in dev mode.

That does leave a related wart untouched: in dev mode the neutralized query is served as [{"products.count": "0"}], because the 1 = 0 segment turns a policy denial into a row that reads as a legitimate answer. It's out of scope here, but happy to follow up separately if you'd like it addressed.

Tests

  • packages/cubejs-server-core/test/unit/CompilerApi.test.ts — new applyRowLevelSecurity unit tests: an allowed query is not denied, a denied query reports every ungranted member (not just the first) and is neutralized with the rlsAccessDenied segment, and a user matched by no policy at all is denied.
  • packages/cubejs-api-gateway/test/index.test.ts/load (GET and POST) returns 403 naming the denied member, a member denied via a filter is named too, and a member the request never asked for is kept out of the response body.
  • packages/cubejs-testing/test/smoke-rbac.test.ts and smoke-rbac-graphql.test.ts — updated to the new error, plus assertions that the response status is 403 and that the denied member is named. The dev-mode tests are unchanged.

Ran locally, all green: cubejs-server-core 92/92, cubejs-api-gateway 239/239, smoke-rbac-graphql 4/4, and smoke-rbac 91/94 against a birdbox built from this branch. The 3 remaining smoke-rbac failures are all [Python config] blocks failing to boot on a fallback build of the native extension ("Unable to load Python configuration because you are using the fallback build of native extension"), unrelated to this change. eslint clean on all changed files.

Docs

  • docs/data-modeling/data-access-policies.mdx — the "Access is denied (an empty result)" bullet now states the actual per-API behavior.
  • docs/data-modeling/access-control/member-level-security.mdx — short note on what a caller gets when querying a member their group has no access to.

A query an access policy denied member-level access to was still executed.
It returned no rows and then failed while the result was transformed, with
"You requested hidden member: '<name>'. Please make it visible using
`public: true`...", surfaced to the caller as HTTP 500.

That conflated an authorization failure with a server fault and advised
weakening the very policy that did the denying.

`applyRowLevelSecurity` already detects the denial before execution, so it
now reports which members were denied and the gateway rejects the data APIs
(`/load` — REST, GraphQL and subscriptions) with `403 Forbidden`.

The message names the denied members the request itself asked for: the
caller supplied those names, and a member that doesn't exist already fails
differently (`400`, "not found for path"), so withholding them would hide
nothing while making a denial hard to act on. Policies are evaluated over
the members the generated SQL touches, which pulls in members the caller
never named (a cube's primary key, for one) — those are logged server-side
only.

Dev mode is deliberately left as it was. It doesn't enforce security checks,
so a request without a token — the playground's normal state — carries no
security context, resolves to no groups, and is denied by every policy.
Failing those would break the playground for any model using access
policies, so the denial is only logged there.

The SQL API keeps its empty-result semantics, and members hidden by
`public: false` outside of RBAC keep their existing message, where the
`public: true` advice is the correct guidance.

Fixes cube-js#11769

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@allanmaclean
allanmaclean requested review from a team and keydunov as code owners September 10, 2026 02:32
@github-actions github-actions Bot added javascript Pull requests that update Javascript code pr:community Contribution from Cube.js community members. labels Sep 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

javascript Pull requests that update Javascript code pr:community Contribution from Cube.js community members.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RBAC member-level denial returns an unclassified HTTP 500 with misleading guidance (REST API)

1 participant