RBAC member-level denial returns an unclassified HTTP 500 with misleading guidance (REST API)
RBAC member-level denial returns an unclassified HTTP 500 with misleading guidance (REST API)
Summary
When an access_policy member_level rule denies a requested member, the REST API returns:
HTTP 500
{
"error": "Error: You requested hidden member: 'orders_view.status'. Please make it visible using `public: true`. Please note primaryKey fields are `public: false` by default: https://cube.dev/docs/schema/reference/joins#setting-a-primary-key."
}Two problems:
- The status code is
500, so an authorization outcome is indistinguishable from a genuine server/warehouse failure. - The guidance is wrong for this path - it advises setting
public: trueand mentions primary keys, neither of which relates to an RBAC denial. Following the advice would mean weakening the security control that correctly denied the request.
The message also discloses the restricted member name to an unauthorized caller.
Version: cubejs/cube:v1.7.23 (Docker), REST API (POST /cubejs-api/v1/load), Cube Core.
Why it is a 500
The denial is raised in the Rust orchestrator as a plain bail!:
https://github.com/cube-js/cube/blob/fe089407075bb4f24760dbf601985a68ed684e6a/rust/cube/cubeorchestrator/src/query_result_transform.rs#L303-L316
Because it is neither a CubejsHandlerError nor a UserError, handleError falls through every typed branch to the final else:
https://github.com/cube-js/cube/blob/fe089407075bb4f24760dbf601985a68ed684e6a/packages/cubejs-api-gateway/src/gateway.ts#L2542-L2550
The "Error: " prefix in the response body is the e.toString() fingerprint of that branch.
Reproduction
Data model - policy on a view, cube kept private (per the style guide's "Cubes should remain private; only views can be exposed"):
# cubes/orders.yml
cubes:
- name: orders
sql_table: ANALYTICS.ORDERS
public: false
dimensions:
- name: status
sql: STATUS
type: string
- name: id
sql: ID
type: string
primary_key: true
measures:
- name: count
type: count# views/orders_view.yml
views:
- name: orders_view
cubes:
- join_path: orders
includes:
- status
- count
access_policy:
- group: admin
row_level: { allow_all: true }
member_level: { includes: "*" }
- group: viewer
row_level: { allow_all: true }
member_level:
excludes:
- status // cube.js
module.exports = {
contextToGroups: ({ securityContext }) => (securityContext.role ?
[securityContext.role] : []),
};Request with a JWT carrying { "role": "viewer" }:
curl -s -X POST "$CUBE_URL/cubejs-api/v1/load" \
-H "Authorization: $VIEWER_JWT" -H 'Content-Type: application.json' \
-d '{"query":{"measures":["orders_view_count"],"dimensions":["orders_view.status"]}}'Actual: 500 with the message above.
Expected: a 4xx (e.g. 403 Forbidden) with a typed error identifying this as an access-control outcome.
Control cases behave correctly, confirming the policy itself works as documented:
| Token | Query | Result |
|---|---|---|
role: viewer |
count |
200, real value |
role: viewer |
count + status |
500 hidden member status only |
role: admin |
count + status |
200, per-status rows |
no role claim |
count |
500 hidden member count (fail-closed, no policy matches) |
GET /meta as viewer |
- | status correctly absent |
How this arose
This appears to be a side effect of #10590 (c95317be96, 2026-03-31), whose stated goal was about GraphQL schema caching - "Addresses the GraphQL schema caching issue causing intermittent 400s when different security context share a CompilerApi instance."
Before that PR, an RBAC member denial returned a silent 200 with empty data. The removed test asserted exactly that, with a TODO naming the desired fix:
// When querying hidden members, row-level security denies access
// by filtering out all rows (returns empty result)
// TODO we should evaluate member access before the query runs and bounce early with an error
const hiddenMemberResult = await client.load(query, {});
expect(hiddenMemberResult.rawData()).toEqual([]);Turning silence into a loud error was a clear improvement, and it addressed the correctness half of the aforementioned TODO. But two details left the REST surface in an awkward state:
- The check stayed after query execution, in Rust, rather than "before the query runs" as the TODO suggested. The Rust layer has no access to
CubejsHandlerError, so it cannot express a status code - hence the fallback 500. The PR chose Rust-side validation deliberately ("this validation is redundant because the Rust-side result transform later can perform this check") to avoid duplicating logic ingraphql.ts, which is reasonable, but it placed an enforcement in a later that cannot classify its own errors. ensure_member_in_annotationwas extracted from three call sites, one of which isget_vanilla_row, where "make it visible usingpublic: true" is genuinely apt. The RBAC-denial path inherited advice written for a different situation.
REST was not the PR's target - the gateway.ts and CompilerApi.ts changes are scoped to the /graphql route - but query_result_transform.rs is on the shared result-transform path, so REST inherited the new behavior.
Suggested fix
Any fix needs to keep GraphQL secure. Since #10590 catches an unfiltered GraphQL schema (skipVisibilityPatch: true), the query-time annotationcheck is now GraphQL's only member-level gate - so reverting to empty results in not an option.
Options, roughly in order of prefrence:
- Evaluate member access in JS before execution and throw a typed error - what the original TODO suggested.
applyRowLevelSecurityalready computes exactly this (cubeAccessDeniedinCompilerApi.ts) before any SQL runs; that site could thrownew CubejsHandlerError(403, 'Forbidden', ...)instead of injecting the1=0segment. The Rust check remains as defence in depth for both protocols. - Propagate a distinguishable error type from Rust so
handleErrorcan map it to a4xxrather than the catch-all 500. - At minimum, fix the message for the RBAC path - drop the
public: true/ primary-key advice when the cause is an access policy, and consider omitting the member name for unauthorized callers.
Happy to attempt a PR for (1) if that direction seems right.
Source: cube-js/cube