RBAC member-level denial returns an unclassified HTTP 500 with misleading guidance (REST API)
- 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
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