cube-js / cube-js/cube

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

Open
#11,769 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Rust
Stars
20.8k
Forks
2.1k
Avg merge
1d 10h
Merged PRs (30d)
203

Description

# 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!`:
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"):
```yaml
# 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
```
```yaml
# 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
```
```js
// 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](https://github.com/cube-js/cube/pull/10590) ([`c95317be96`](https://github.com/cube-js/cube/commit/c95317be96d51d381dfd4c782d88205b3f5730bb), 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:

```js
// 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](https://github.com/cube-js/cube/pull/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.

Contributor guide

Open the contributing guide

Research direction

Reproduce the viewer request described in the issue, then read rust/cube/cubeorchestrator/src/query_result_transform.rs around the cited denial and packages/cubejs-api-gateway/src/gateway.ts around handleError. Trace applyRowLevelSecurity and cubeAccessDenied in CompilerApi.ts to understand the shared path. Done means RBAC denials remain secure, return a typed 4xx response, and no longer expose misleading visibility guidance.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust, typescript
Domain
api, backend, security
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.