Skip to content
Merged
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
2 changes: 1 addition & 1 deletion packages/cubejs-api-gateway/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"typings": "dist/src/index.d.ts",
"scripts": {
"test": "npm run unit",
"unit": "jest --coverage dist/test",
"unit": "CUBE_JS_NATIVE_API_GATEWAY_INTERNAL=true jest --coverage --forceExit dist/test",
Copy link
Member Author

@ovr ovr Mar 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Motivation:

--force exit is a workaround for a leaked handler somewhere in the rust part. I figure out one with setupLogger, and most probably, another part is registerInterface. It's an old known bug, and I don't plan to fix it in the scope of this PR

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thnx for the explanation!

"build": "rm -rf dist && npm run tsc",
"tsc": "tsc",
"watch": "tsc -w",
Expand Down
2 changes: 1 addition & 1 deletion packages/cubejs-api-gateway/src/SubscriptionServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export class SubscriptionServer {
}

if (message.authorization) {
authContext = { isSubscription: true };
authContext = { isSubscription: true, protocol: 'ws' };
await this.apiGateway.checkAuthFn(authContext, message.authorization);
const acceptanceResult = await this.contextAcceptor(authContext);
if (!acceptanceResult.accepted) {
Expand Down
49 changes: 36 additions & 13 deletions packages/cubejs-api-gateway/src/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,10 @@ function systemAsyncHandler(handler: (req: Request & { context: ExtendedRequestC
};
}

// Prepared CheckAuthFn, default or from config: always async, returns nothing
type PreparedCheckAuthFn = (ctx: any, authorization?: string) => Promise<void>;
// Prepared CheckAuthFn, default or from config: always async
type PreparedCheckAuthFn = (ctx: any, authorization?: string) => Promise<{
securityContext: any;
}>;

class ApiGateway {
protected readonly refreshScheduler: any;
Expand All @@ -148,9 +150,9 @@ class ApiGateway {

public readonly checkAuthSystemFn: PreparedCheckAuthFn;

protected readonly contextToApiScopesFn: ContextToApiScopesFn;
public readonly contextToApiScopesFn: ContextToApiScopesFn;

protected readonly contextToApiScopesDefFn: ContextToApiScopesFn =
public readonly contextToApiScopesDefFn: ContextToApiScopesFn =
async () => ['graphql', 'meta', 'data'];

protected readonly requestLoggerMiddleware: RequestLoggerMiddlewareFn;
Expand Down Expand Up @@ -544,20 +546,24 @@ class ApiGateway {
}

if (getEnv('nativeApiGateway')) {
const proxyMiddleware = createProxyMiddleware<Request, Response>({
target: `http://127.0.0.1:${this.sqlServer.getNativeGatewayPort()}/v2`,
changeOrigin: true,
});

app.use(
`${this.basePath}/v2`,
proxyMiddleware as any
);
this.enableNativeApiGateway(app);
}

app.use(this.handleErrorMiddleware);
}

protected enableNativeApiGateway(app: ExpressApplication) {
const proxyMiddleware = createProxyMiddleware<Request, Response>({
target: `http://127.0.0.1:${this.sqlServer.getNativeGatewayPort()}/v2`,
changeOrigin: true,
});

app.use(
`${this.basePath}/v2`,
proxyMiddleware as any
);
}

public initSubscriptionServer(sendMessage: WebSocketSendMessageFn) {
return new SubscriptionServer(this, sendMessage, this.subscriptionStore, this.wsContextAcceptor);
}
Expand Down Expand Up @@ -2250,6 +2256,10 @@ class ApiGateway {

showWarningAboutNotObject = true;
}

return {
securityContext: req.securityContext
};
};
}

Expand Down Expand Up @@ -2333,6 +2343,10 @@ class ApiGateway {
// @todo Move it to 401 or 400
throw new CubejsHandlerError(403, 'Forbidden', 'Authorization header isn\'t set');
}

return {
securityContext: req.securityContext
};
};
}

Expand All @@ -2343,6 +2357,7 @@ class ApiGateway {

if (this.playgroundAuthSecret) {
const systemCheckAuthFn = this.createCheckAuthSystemFn();

return async (ctx, authorization) => {
// TODO: separate two auth workflows
try {
Expand All @@ -2354,6 +2369,10 @@ class ApiGateway {
throw mainAuthError;
}
}

return {
securityContext: ctx.securityContext,
};
};
}

Expand All @@ -2371,6 +2390,10 @@ class ApiGateway {

return async (ctx, authorization) => {
await systemCheckAuthFn(ctx, authorization);

return {
securityContext: ctx.securityContext
};
};
}

Expand Down
11 changes: 11 additions & 0 deletions packages/cubejs-api-gateway/src/sql-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,17 @@ export class SQLServer {
this.sqlInterfaceInstance = await registerInterface({
gatewayPort: this.gatewayPort,
pgPort: options.pgSqlPort,
contextToApiScopes: async ({ securityContext }) => this.apiGateway.contextToApiScopesFn(
securityContext,
getEnv('defaultApiScope') || await this.apiGateway.contextToApiScopesDefFn()
),
checkAuth: async ({ request, token }) => {
const { securityContext } = await this.apiGateway.checkAuthFn(request, token);

return {
securityContext
};
},
checkSqlAuth: async ({ request, user, password }) => {
const { password: returnedPassword, superuser, securityContext, skipPasswordCheck } = await checkSqlAuth(request, user, password);

Expand Down
157 changes: 150 additions & 7 deletions packages/cubejs-api-gateway/test/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,57 @@ import express, { Application as ExpressApplication, RequestHandler } from 'expr
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { pausePromise } from '@cubejs-backend/shared';
import { resetLogger } from '@cubejs-backend/native';

import { ApiGateway, ApiGatewayOptions, CubejsHandlerError, Request } from '../src';
import { ApiGateway, ApiGatewayOptions, CubejsHandlerError, Request, RequestContext } from '../src';
import { AdapterApiMock, DataSourceStorageMock } from './mocks';
import { RequestContext } from '../src/interfaces';
import { generateAuthToken } from './utils';

class ApiGatewayOpenAPI extends ApiGateway {
protected isRunning: Promise<void> | null = null;

public coerceForSqlQuery(query, context: RequestContext) {
return super.coerceForSqlQuery(query, context);
}

public async startSQLServer(): Promise<void> {
if (this.isRunning) {
return this.isRunning;
}

this.isRunning = this.sqlServer.init({});

return this.isRunning;
}

public async shutdownSQLServer(): Promise<void> {
try {
await this.sqlServer.shutdown('fast');
} finally {
this.isRunning = null;
}

// SQLServer changes logger for rust side with setupLogger in the constructor, but it leads
// to a memory leak, that's why jest doesn't allow to shut down tests
resetLogger(
process.env.CUBEJS_LOG_LEVEL === 'trace' ? 'trace' : 'warn'
);
}
}

function createApiGateway(handler: RequestHandler, logger: () => any, options: Partial<ApiGatewayOptions>) {
const adapterApi: any = new AdapterApiMock();
const dataSourceStorage: any = new DataSourceStorageMock();

class ApiGatewayFake extends ApiGateway {
public coerceForSqlQuery(query, context: RequestContext) {
return super.coerceForSqlQuery(query, context);
}

class ApiGatewayFake extends ApiGatewayOpenAPI {
public initApp(app: ExpressApplication) {
const userMiddlewares: RequestHandler[] = [
this.checkAuth,
this.requestContextMiddleware,
];

app.get('/test-auth-fake', userMiddlewares, handler);
this.enableNativeApiGateway(app);

app.use(this.handleErrorMiddleware);
}
Expand All @@ -41,6 +70,7 @@ function createApiGateway(handler: RequestHandler, logger: () => any, options: P
});

process.env.NODE_ENV = 'unknown';

const app = express();
apiGateway.initApp(app);

Expand All @@ -50,6 +80,119 @@ function createApiGateway(handler: RequestHandler, logger: () => any, options: P
};
}

describe('test authorization with native gateway', () => {
let app: ExpressApplication;
let apiGateway: ApiGatewayOpenAPI;

const handlerMock = jest.fn(() => {
// nothing, we are using it to verify that we don't got to express code
});
const loggerMock = jest.fn(() => {
//
});
const checkAuthMock = jest.fn((req, token) => {
jwt.verify(token, 'secret');

return {
security_context: {}
};
});

beforeAll(async () => {
const result = createApiGateway(handlerMock, loggerMock, {
checkAuth: checkAuthMock,
gatewayPort: 8585,
});

app = result.app;
apiGateway = result.apiGateway;

await result.apiGateway.startSQLServer();
});

beforeEach(() => {
handlerMock.mockClear();
loggerMock.mockClear();
checkAuthMock.mockClear();
});

afterAll(async () => {
await apiGateway.shutdownSQLServer();
});

it('default authorization - success', async () => {
const token = generateAuthToken({ uid: 5, });

await request(app)
.get('/cubejs-api/v2/stream')
.set('Authorization', `${token}`)
.send()
.expect(501);

// No bad logs
expect(loggerMock.mock.calls.length).toEqual(0);
// We should not call js handler, request should go into rust code
expect(handlerMock.mock.calls.length).toEqual(0);

// Verify that we passed token to JS side
expect(checkAuthMock.mock.calls.length).toEqual(1);
expect(checkAuthMock.mock.calls[0][0].protocol).toEqual('http');
expect(checkAuthMock.mock.calls[0][1]).toEqual(token);
});

it('default authorization - success (bearer prefix)', async () => {
const token = generateAuthToken({ uid: 5, });

await request(app)
.get('/cubejs-api/v2/stream')
.set('Authorization', `Bearer ${token}`)
.send()
.expect(501);

// No bad logs
expect(loggerMock.mock.calls.length).toEqual(0);
// We should not call js handler, request should go into rust code
expect(handlerMock.mock.calls.length).toEqual(0);

// Verify that we passed token to JS side
expect(checkAuthMock.mock.calls.length).toEqual(1);
expect(checkAuthMock.mock.calls[0][0].protocol).toEqual('http');
expect(checkAuthMock.mock.calls[0][1]).toEqual(token);
});

it('default authorization - wrong secret', async () => {
const badToken = 'SUPER_LARGE_BAD_TOKEN_WHICH_IS_NOT_A_TOKEN';

await request(app)
.get('/cubejs-api/v2/stream')
.set('Authorization', `${badToken}`)
.send()
.expect(401);

// No bad logs
expect(loggerMock.mock.calls.length).toEqual(0);
// We should not call js handler, request should go into rust code
expect(handlerMock.mock.calls.length).toEqual(0);

// Verify that we passed token to JS side
expect(checkAuthMock.mock.calls.length).toEqual(1);
expect(checkAuthMock.mock.calls[0][0].protocol).toEqual('http');
expect(checkAuthMock.mock.calls[0][1]).toEqual(badToken);
});

it('default authorization - missing auth header', async () => {
await request(app)
.get('/cubejs-api/v2/stream')
.send()
.expect(401);

// No bad logs
expect(loggerMock.mock.calls.length).toEqual(0);
// We should not call js handler, request should go into rust code
expect(handlerMock.mock.calls.length).toEqual(0);
});
});

describe('test authorization', () => {
test('default authorization', async () => {
const loggerMock = jest.fn(() => {
Expand Down
Loading
Loading