Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions packages/cubejs-api-gateway/src/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,12 +290,14 @@ class ApiGateway {
);

const compilerApi = await this.getCompilerApi(req.context);

// Cache unfiltered schema - RBAC enforcement happens at query execution time
let schema = compilerApi.getGraphQLSchema();
if (!schema) {
let metaConfig = await compilerApi.metaConfig(req.context, {
const metaConfig = await compilerApi.metaConfig(req.context, {
requestId: req.context.requestId,
skipVisibilityPatch: true,
});
metaConfig = this.filterVisibleItemsInMeta(req.context, metaConfig);
schema = makeSchema(metaConfig);
compilerApi.setGraphQLSchema(schema);
}
Expand Down
41 changes: 41 additions & 0 deletions packages/cubejs-api-gateway/src/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,47 @@ export function makeSchema(metaConfig: any): GraphQLSchema {
resolve: async (_, args, { req, res, apiGateway }, info) => {
const query = getJsonQuery(metaConfig, args, info);

// Validate that all requested members are accessible by this security context
const requestedMembers = [
...(query.measures || []),
...(query.dimensions || []),
...(query.segments || []),
...(query.timeDimensions || []).map((td: any) => td.dimension),
];

if (requestedMembers.length > 0 && req.context) {
// Get RBAC-filtered metadata for this security context
const compilerApi = await apiGateway.getCompilerApi(req.context);
const filteredMetaConfig = await compilerApi.metaConfig(req.context, {
requestId: req.context.requestId,
});

// Build set of allowed (visible) members
const allowedMembers = new Set<string>();
filteredMetaConfig.forEach((cube: any) => {
cube.config.measures?.forEach((m: any) => {
if (m.isVisible) allowedMembers.add(m.name);
});
cube.config.dimensions?.forEach((d: any) => {
if (d.isVisible) allowedMembers.add(d.name);
});
cube.config.segments?.forEach((s: any) => {
if (s.isVisible) allowedMembers.add(s.name);
});
});

// Check if any requested member is hidden
const hiddenMembers = requestedMembers.filter(m => !allowedMembers.has(m));
if (hiddenMembers.length > 0) {
throw new Error(
`You requested hidden member: '${hiddenMembers[0]}'. ` +
'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.'
);
}
}

const results = await new Promise<any>((resolve, reject) => {
apiGateway.load({
query,
Expand Down
13 changes: 11 additions & 2 deletions packages/cubejs-server-core/src/core/CompilerApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -959,11 +959,20 @@ export class CompilerApi {

public async metaConfig(
requestContext: Context,
options: { includeCompilerId?: boolean; requestId?: string } = {}
options: { includeCompilerId?: boolean; skipVisibilityPatch?: boolean; requestId?: string } = {}
): Promise<any> {
const { includeCompilerId, ...restOptions } = options;
const { includeCompilerId, skipVisibilityPatch, ...restOptions } = options;
const compilers = await this.getCompilers(restOptions);
const { cubes } = compilers.metaTransformer;

// When skipVisibilityPatch is true, return raw cubes without RBAC filtering
if (skipVisibilityPatch) {
if (includeCompilerId) {
return { cubes, compilerId: compilers.compilerId };
}
return cubes;
}

const { visibilityMaskHash, cubes: patchedCubes } = await this.patchVisibilityByAccessPolicy(
compilers,
requestContext,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Cube.js configuration for testing GraphQL schema caching with different security contexts
module.exports = {
// Map security context to RBAC roles
contextToRoles: ({ securityContext }) => securityContext?.auth?.roles || ['*'],

// SAME app ID for all tenants - this forces them to share a CompilerApi instance
contextToAppId: () => 'CUBEJS_APP_shared',
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Orders cube with RBAC-based field visibility using access policies
// This tests the GraphQL schema caching bug where different tenants should see different fields

cube('Orders', {
sql: `SELECT 1 as id, 100 as amount, 'secret123' as internal_code, 'premium' as tier`,

measures: {
count: {
type: 'count',
},
totalAmount: {
sql: 'amount',
type: 'sum',
},
},

dimensions: {
id: {
sql: 'id',
type: 'number',
primaryKey: true,
},
amount: {
sql: 'amount',
type: 'number',
},
// This field should only be visible to tenant-a
internalCode: {
sql: 'internal_code',
type: 'string',
},
// This field should only be visible to tenant-b
tier: {
sql: 'tier',
type: 'string',
},
},

// RBAC access policies - complete denial for default role
accessPolicy: [
{
// Default: complete denial - no members accessible (triggers "You requested hidden member" error)
role: '*',
memberLevel: {
includes: [],
},
},
{
// tenant-a: can access all EXCEPT tier
role: 'tenant-a',
memberLevel: {
includes: '*',
excludes: ['tier'],
},
},
{
// tenant-b: can access all EXCEPT internalCode
role: 'tenant-b',
memberLevel: {
includes: '*',
excludes: ['internalCode'],
},
},
],
});
153 changes: 153 additions & 0 deletions packages/cubejs-testing/test/smoke-rbac-graphql.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/**
* Integration test for GraphQL schema caching and RBAC enforcement.
*
* This test verifies that:
* 1. GraphQL schema is cached (unfiltered) - all users see all fields in schema
* 2. RBAC is enforced at query execution time
* 3. Both complete denial (includes: []) and partial denial (excludes: [...]) return errors
*/

// eslint-disable-next-line import/no-extraneous-dependencies
import { afterAll, beforeAll, jest, expect } from '@jest/globals';
import { sign } from 'jsonwebtoken';
import fetch from 'node-fetch';

import { BirdBox, getBirdbox } from '../src';
import {
DEFAULT_CONFIG,
JEST_AFTER_ALL_DEFAULT_TIMEOUT,
JEST_BEFORE_ALL_DEFAULT_TIMEOUT,
} from './smoke-tests';

describe('GraphQL Schema Caching and RBAC', () => {
jest.setTimeout(60 * 5 * 1000);
let birdbox: BirdBox;

beforeAll(async () => {
birdbox = await getBirdbox(
'duckdb',
{
...DEFAULT_CONFIG,
CUBEJS_DEV_MODE: 'false',
NODE_ENV: 'production',
CUBEJS_DB_TYPE: 'duckdb',
},
{
schemaDir: 'rbac-graphql/model',
cubejsConfig: 'rbac-graphql/cube.js',
}
);
}, JEST_BEFORE_ALL_DEFAULT_TIMEOUT);

afterAll(async () => {
await birdbox.stop();
}, JEST_AFTER_ALL_DEFAULT_TIMEOUT);

async function graphqlRequest(role: string, query: string): Promise<any> {
const token = sign({
auth: { roles: [role] },
}, DEFAULT_CONFIG.CUBEJS_API_SECRET, { expiresIn: '1h' });

const baseUrl = birdbox.configuration.apiUrl.replace('/cubejs-api/v1', '');
const res = await fetch(`${baseUrl}/cubejs-api/graphql`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ query }),
});
return res.json();
}

test('all roles see the same unfiltered schema', async () => {
const introspectionQuery = '{ __type(name: "OrdersMembers") { fields { name } } }';

const resultA = await graphqlRequest('tenant-a', introspectionQuery);
const resultB = await graphqlRequest('tenant-b', introspectionQuery);
const resultDefault = await graphqlRequest('default', introspectionQuery);

const fieldsA = resultA.data?.__type?.fields?.map((f: any) => f.name) || [];
const fieldsB = resultB.data?.__type?.fields?.map((f: any) => f.name) || [];
const fieldsDefault = resultDefault.data?.__type?.fields?.map((f: any) => f.name) || [];

// All roles see the same schema with all fields
expect(fieldsA).toEqual(fieldsB);
expect(fieldsA).toEqual(fieldsDefault);
expect(fieldsA).toContain('internalCode');
expect(fieldsA).toContain('tier');
});

test('tenant-a can query internalCode but not tier', async () => {
// Query allowed field - should return data
const allowedResult = await graphqlRequest('tenant-a', `{
cube(where: { orders: {} }) {
orders { internalCode }
}
}`);
expect(allowedResult.errors).toBeUndefined();
expect(allowedResult.data.cube).toHaveLength(1);
expect(allowedResult.data.cube[0].orders.internalCode).toBe('secret123');

// Query restricted field - should return error (RBAC denies access)
const restrictedResult = await graphqlRequest('tenant-a', `{
cube(where: { orders: {} }) {
orders { tier }
}
}`);
expect(restrictedResult.errors).toBeDefined();
expect(restrictedResult.errors[0].message).toContain('You requested hidden member');
});

test('tenant-b can query tier but not internalCode', async () => {
// Query allowed field - should return data
const allowedResult = await graphqlRequest('tenant-b', `{
cube(where: { orders: {} }) {
orders { tier }
}
}`);
expect(allowedResult.errors).toBeUndefined();
expect(allowedResult.data.cube).toHaveLength(1);
expect(allowedResult.data.cube[0].orders.tier).toBe('premium');

// Query restricted field - should return error (RBAC denies access)
const restrictedResult = await graphqlRequest('tenant-b', `{
cube(where: { orders: {} }) {
orders { internalCode }
}
}`);
expect(restrictedResult.errors).toBeDefined();
expect(restrictedResult.errors[0].message).toContain('You requested hidden member');
});

test('default role cannot query any fields - complete denial returns errors', async () => {
// With includes: [] (complete denial), GraphQL returns errors for hidden members

// Query internalCode - should return error (complete denial)
const result1 = await graphqlRequest('default', `{
cube(where: { orders: {} }) {
orders { internalCode }
}
}`);
expect(result1.errors).toBeDefined();
expect(result1.errors[0].message).toContain('You requested hidden member');

// Query tier - should also return error (complete denial)
const result2 = await graphqlRequest('default', `{
cube(where: { orders: {} }) {
orders { tier }
}
}`);
expect(result2.errors).toBeDefined();
expect(result2.errors[0].message).toContain('You requested hidden member');

// Query count - should also return error (complete denial with includes: [])
const result3 = await graphqlRequest('default', `{
cube(where: { orders: {} }) {
orders { count }
}
}`);
expect(result3.errors).toBeDefined();
expect(result3.errors[0].message).toContain('You requested hidden member');
});
});
Loading