Skip to content

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

Description

@allanmaclean

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:

  1. The status code is 500, so an authorization outcome is indistinguishable from a genuine server/warehouse failure.
  2. 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:

  1. 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.
  2. Propagate a distinguishable error type from Rust so handleError can map it to a 4xx rather than the catch-all 500.
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions