Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,11 @@ This configuration results in the following access:
| `guest` | Only the `count_30d` measure |
| All other users | No access to this view at all |

Querying a member that a group has no access to is refused rather than silently
ignored: the [REST][ref-rest-api] and [GraphQL][ref-graphql-api] APIs respond
with `403 Forbidden`, naming the denied members the query asked for, and the
[SQL API][ref-sql-api] returns an empty result.

Access policies also respect member-level security restrictions configured via
`public` parameters. For more details, see the [access policies
reference][ref-dap-ref].
Expand Down Expand Up @@ -161,4 +166,7 @@ them entirely, see [data masking][ref-data-masking] in access policies.
[ref-segments-public]: /reference/data-modeling/segments#public
[ref-dynamic-data-modeling]: /docs/data-modeling/dynamic
[ref-security-context]: /docs/data-modeling/access-control/context
[ref-data-masking]: /docs/data-modeling/data-access-policies#data-masking
[ref-data-masking]: /docs/data-modeling/data-access-policies#data-masking
[ref-rest-api]: /reference/core-data-apis/rest-api
[ref-graphql-api]: /reference/core-data-apis/graphql-api
[ref-sql-api]: /reference/core-data-apis/sql-api
7 changes: 5 additions & 2 deletions docs-mintlify/docs/data-modeling/data-access-policies.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,11 @@ every region granted by every matching policy:
- **A member is masked** when no granting policy gives it unconditional full
access through `member_level`, but some matching policy lists it under
`member_masking`.
- **Access is denied** (an empty result) only when a queried member is granted
by **no** matching policy at all.
- **Access is denied** only when a queried member is granted by **no** matching
policy at all. The [REST API](/reference/core-data-apis/rest-api) and
[GraphQL API](/reference/core-data-apis/graphql-api) reject such a query with
`403 Forbidden`, naming the denied members it asked for; the
[SQL API](/reference/core-data-apis/sql-api) returns an empty result.

#### Diagram and behavior

Expand Down
119 changes: 115 additions & 4 deletions packages/cubejs-api-gateway/src/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1324,14 +1324,20 @@ class ApiGateway {
/**
* Convert incoming query parameter (JSON fetched from the HTTP) to
* an array of query type and array of normalized queries.
*
* The last element lists the members an access policy refused member-level
* access to (empty when nothing was denied). Denied queries are still
* normalized — `applyRowLevelSecurity` neutralizes them with a `1 = 0`
* segment — so each API can decide whether that's an empty result (SQL API)
* or an error (data APIs, see `load`).
*/
protected async getNormalizedQueries(
inputQuery: Record<string, any> | Record<string, any>[],
context: RequestContext,
persistent = false,
memberExpressions: boolean = false,
cacheMode?: CacheMode,
): Promise<[QueryType, NormalizedQuery[], NormalizedQuery[]]> {
): Promise<[QueryType, NormalizedQuery[], NormalizedQuery[], string[]]> {
let query = this.parseQueryParam(inputQuery);

let queryType: QueryType = QueryTypeEnum.REGULAR_QUERY;
Expand Down Expand Up @@ -1380,6 +1386,8 @@ class ApiGateway {
};
});

const deniedMembers = new Set<string>();

let normalizedQueries: NormalizedQuery[] = await Promise.all(
queryNormalizationResult.map(
async ({ normalizedQuery, hasExpressionsInQuery }) => {
Expand All @@ -1392,11 +1400,16 @@ class ApiGateway {
}

// First apply cube/view level security policies
const { query: queryWithRlsFilters, denied } = await compilerApi.applyRowLevelSecurity(
const {
query: queryWithRlsFilters,
denied,
deniedMembers: queryDeniedMembers,
} = await compilerApi.applyRowLevelSecurity(
normalizedQuery,
evaluatedQuery,
context
);
(queryDeniedMembers || []).forEach((member: string) => deniedMembers.add(member));
// Then apply user-supplied queryRewrite
let rewrittenQuery = !denied ? await this.queryRewrite(
queryWithRlsFilters,
Expand Down Expand Up @@ -1443,7 +1456,12 @@ class ApiGateway {
}
}

return [queryType, normalizedQueries, queryNormalizationResult.map((it) => remapToQueryAdapterFormat(it.normalizedQuery))];
return [
queryType,
normalizedQueries,
queryNormalizationResult.map((it) => remapToQueryAdapterFormat(it.normalizedQuery)),
Array.from(deniedMembers),
];
}

protected async sql4sql({
Expand Down Expand Up @@ -2006,6 +2024,97 @@ class ApiGateway {
}
}

/**
* Collects the member names a request asked for, including the members
* referenced by its filters and the dimension behind a `dimension.granularity`
* path.
*/
private requestedMemberNames(query: Query | Query[] | undefined): Set<string> {
const names = new Set<string>();

const addName = (member: unknown) => {
if (typeof member !== 'string') {
return;
}
names.add(member);
// `orders.created_at.month` references the `orders.created_at` dimension
const parts = member.split('.');
if (parts.length > 2) {
names.add(parts.slice(0, 2).join('.'));
}
};

const addFilters = (filters: any[] | undefined) => {
for (const filter of filters || []) {
if (filter?.and || filter?.or) {
addFilters(filter.and || filter.or);
} else {
addName(filter?.member || filter?.dimension);
}
}
};

const queries = (Array.isArray(query) ? query : [query]).filter(Boolean) as Query[];

for (const currentQuery of queries) {
(currentQuery.measures || []).forEach(addName);
(currentQuery.dimensions || []).forEach(addName);
(currentQuery.segments || []).forEach(addName);
(currentQuery.timeDimensions || []).forEach((td: any) => addName(td?.dimension));
addFilters(currentQuery.filters);
}

return names;
}

/**
* Fails the request when an access policy denied member-level access to any
* of the queried members.
*
* Such a query is not a server fault and must not be answered with the data
* it asks for, so it's reported as `403 Forbidden` rather than surfacing later
* as an internal error while the (deliberately empty) result is transformed.
*
* The message names the denied members the request itself asked for — the
* caller supplied those names, and a member that doesn't exist already fails
* differently (`400`, "not found for path"), so withholding them would hide
* nothing while making a denial hard to act on. Policies are evaluated over
* the members the generated SQL touches, though, which pulls in members the
* caller never named (a cube's primary key, for one); those are logged only.
*
* Dev mode is left alone: it doesn't enforce security checks, so a request
* without a token — the playground's normal state — carries no security
* context, resolves to no groups, and is therefore denied by every policy.
* Failing those would break the playground for any model using access
* policies, so the denial is only logged there.
*/
protected assertMemberAccess(deniedMembers: string[], query: Query | Query[] | undefined, context: RequestContext) {
if (!deniedMembers.length) {
return;
}

this.log({
type: 'Access Policy Denied',
query,
deniedMembers,
}, context);

if (getEnv('devMode')) {
return;
}

const requested = this.requestedMemberNames(query);
const reportableMembers = deniedMembers.filter(member => requested.has(member)).sort();

throw new CubejsHandlerError(
403,
'Forbidden',
reportableMembers.length
? `Access to the following members is denied by an access policy: ${reportableMembers.join(', ')}`
: 'Access to some of the requested members is denied by an access policy'
);
}

/**
* Data queries APIs (`/load`, `/subscribe`) entry point. Used by
* `CubejsApi#load` and `CubejsApi#subscribe` methods to fetch the
Expand Down Expand Up @@ -2038,9 +2147,11 @@ class ApiGateway {
query
}, context);

const [queryType, normalizedQueries] =
const [queryType, normalizedQueries, , deniedMembers] =
await this.getNormalizedQueries(query, context, false, false, cacheMode);

this.assertMemberAccess(deniedMembers, query, context);

if (
queryType !== QueryTypeEnum.REGULAR_QUERY &&
props.queryType == null
Expand Down
84 changes: 82 additions & 2 deletions packages/cubejs-api-gateway/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
preAggregationsResultFactory,
preAggregationPartitionsResultFactory,
compilerApi,
compilerApiWithAccessDenied,
RefreshSchedulerMock,
DataSourceStorageMock,
AdapterApiMock
Expand Down Expand Up @@ -49,11 +50,12 @@ const API_SECRET = 'secret';
async function createApiGateway(
adapterApi: any = new AdapterApiMock(),
dataSourceStorage: any = new DataSourceStorageMock(),
options: Partial<ApiGatewayOptions> = {}
options: Partial<ApiGatewayOptions> = {},
compilerApiMock: any = compilerApi
) {
process.env.NODE_ENV = 'production';

const apiGateway = new ApiGateway(API_SECRET, compilerApi, async () => adapterApi, logger, {
const apiGateway = new ApiGateway(API_SECRET, compilerApiMock, async () => adapterApi, logger, {
standalone: true,
dataSourceStorage,
basePath: '/cubejs-api',
Expand Down Expand Up @@ -154,6 +156,84 @@ describe('API Gateway', () => {
);
});

test('access policy denial responds with 403 naming the requested members', async () => {
const { app } = await createApiGateway(
new AdapterApiMock(),
new DataSourceStorageMock(),
{},
compilerApiWithAccessDenied(['Foo.bar'])
);

const res = await request(app)
.get('/cubejs-api/v1/load?query={"measures":["Foo.bar"]}')
.set('Authorization', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.t-IDcSemACt8x4iTMCda8Yhe3iZaWbvV5XKSTbuAn0M')
.expect(403);

expect(res.body && res.body.error).toStrictEqual(
'Access to the following members is denied by an access policy: Foo.bar'
);
});

test('access policy denial names a member requested through a filter', async () => {
const { app } = await createApiGateway(
new AdapterApiMock(),
new DataSourceStorageMock(),
{},
compilerApiWithAccessDenied(['Foo.id'])
);

const res = await request(app)
.get(
'/cubejs-api/v1/load?query={"measures":["Foo.bar"],"filters":[{"member":"Foo.id","operator":"equals","values":["1"]}]}'
)
.set('Authorization', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.t-IDcSemACt8x4iTMCda8Yhe3iZaWbvV5XKSTbuAn0M')
.expect(403);

expect(res.body && res.body.error).toStrictEqual(
'Access to the following members is denied by an access policy: Foo.id'
);
});

test('access policy denial keeps members the request never asked for out of the response', async () => {
const { app } = await createApiGateway(
new AdapterApiMock(),
new DataSourceStorageMock(),
{},
// Denied because SQL generation pulls the primary key in, not because the caller asked for it
compilerApiWithAccessDenied(['Foo.id'])
);

const res = await request(app)
.get('/cubejs-api/v1/load?query={"measures":["Foo.bar"]}')
.set('Authorization', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.t-IDcSemACt8x4iTMCda8Yhe3iZaWbvV5XKSTbuAn0M')
.expect(403);

expect(res.body && res.body.error).toStrictEqual(
'Access to some of the requested members is denied by an access policy'
);
expect(JSON.stringify(res.body)).not.toContain('Foo.id');
});

test('access policy denial responds with 403 on POST /load too', async () => {
const { app } = await createApiGateway(
new AdapterApiMock(),
new DataSourceStorageMock(),
{},
compilerApiWithAccessDenied(['Foo.bar'])
);

const res = await request(app)
.post('/cubejs-api/v1/load')
.set('Content-type', 'application/json')
.set('Authorization', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.t-IDcSemACt8x4iTMCda8Yhe3iZaWbvV5XKSTbuAn0M')
.send({ query: { measures: ['Foo.bar'] } })
.expect(403);

expect(res.body && res.body.error).toStrictEqual(
'Access to the following members is denied by an access policy: Foo.bar'
);
});

test('catch error requestContextMiddleware', async () => {
const { app } = await createApiGateway(
new AdapterApiMock(),
Expand Down
14 changes: 14 additions & 0 deletions packages/cubejs-api-gateway/test/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,20 @@ export const compilerApi = jest.fn().mockImplementation(async () => ({
},
}));

/**
* Compiler API whose access policies deny member-level access to `deniedMembers`,
* mirroring what `CompilerApi.applyRowLevelSecurity` returns for an RBAC denial:
* the query is neutralized with a `1 = 0` segment and the denied members are
* reported back to the gateway.
*/
export const compilerApiWithAccessDenied = (deniedMembers: string[]) => jest.fn().mockImplementation(async () => ({
...(await compilerApi()),

async applyRowLevelSecurity(query: any) {
return { query, denied: true, deniedMembers };
},
}));

export class RefreshSchedulerMock {
public async preAggregationPartitions() {
return preAggregationPartitionsResultFactory();
Expand Down
Loading
Loading