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: true and 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!:
|
fn ensure_member_in_annotation( |
|
member: &str, |
|
annotation: &HashMap<String, ConfigItem>, |
|
) -> Result<()> { |
|
if !annotation.contains_key(member) { |
|
bail!( |
|
concat!( |
|
"You requested hidden member: '{}'. 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." |
|
), |
|
member |
|
); |
|
} |
Because it is neither a CubejsHandlerError nor a UserError, handleError falls through every typed branch to the final else:
|
} else { |
|
this.log({ |
|
type: 'Internal Server Error', |
|
query, |
|
error: stack || e.toString(), |
|
duration: this.duration(requestStarted) |
|
}, context); |
|
res({ error: e.toString(), stack, requestId, plainError, }, { status: 500 }); |
|
} |
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 in graphql.ts, which is reasonable, but it placed an enforcement in a later that cannot classify its own errors.
ensure_member_in_annotation was extracted from three call sites, one of which is get_vanilla_row, where "make it visible using public: 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.
applyRowLevelSecurity already computes exactly this (cubeAccessDenied in CompilerApi.ts) before any SQL runs; that site could throw new CubejsHandlerError(403, 'Forbidden', ...) instead of injecting the 1=0 segment. The Rust check remains as defence in depth for both protocols.
- Propagate a distinguishable error type from Rust so
handleError can map it to a 4xx rather 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.
RBAC member-level denial returns an unclassified HTTP 500 with misleading guidance (REST API)
Summary
When an
access_policymember_levelrule denies a requested member, the REST API returns:Two problems:
500, so an authorization outcome is indistinguishable from a genuine server/warehouse failure.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!:cube/rust/cube/cubeorchestrator/src/query_result_transform.rs
Lines 303 to 316 in fe08940
Because it is neither a
CubejsHandlerErrornor aUserError,handleErrorfalls through every typed branch to the finalelse:cube/packages/cubejs-api-gateway/src/gateway.ts
Lines 2542 to 2550 in fe08940
The
"Error: "prefix in the response body is thee.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"):
Request with a JWT carrying
{ "role": "viewer" }:Actual:
500with 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:
role: viewercount200, real valuerole: viewercount+status500hidden memberstatusonlyrole: admincount+status200, per-status rowsroleclaimcount500hidden membercount(fail-closed, no policy matches)GET /metaas viewerstatuscorrectly absentHow 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
200with empty data. The removed test asserted exactly that, with a TODO naming the desired fix: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:
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.tsandCompilerApi.tschanges are scoped to the/graphqlroute - butquery_result_transform.rsis 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:
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.handleErrorcan map it to a4xxrather than the catch-all 500.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.