Skip to content

Commit ee3c19f

Browse files
authored
feat(cubesql): Provide password supplied by Postgres connection as a 3rd argument of check_sql_auth() (#7471)
* feat(cubesql): Provide password supplied by Postgres connection as a 3rd argument of `check_sql_auth()` Fixes #5430 * Add test for password argument * Add authentication expiration * Add docs * Add explicit skip_password_check flag for dev mode, close connection on re-authentication failure, strip CubeError onion of Internal errors * Fix python test * Remove another `Internal:` from snapshot * Remove another `Internal:` from test * Fix docs
1 parent a3c2bbb commit ee3c19f

File tree

22 files changed

+201
-55
lines changed

22 files changed

+201
-55
lines changed

docs/pages/reference/configuration/config.mdx

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1085,20 +1085,27 @@ implementation verifies user name and password from environment variables:
10851085
`CUBEJS_SQL_USER`, `CUBEJS_SQL_PASSWORD`, but in [development
10861086
mode][ref-development-mode] it ignores validation.
10871087

1088-
Called on each request from Cube SQL API.
1088+
Called on each new connection to Cube SQL API, on change user by `SET USER` or `__user` field, every `CUBESQL_AUTH_EXPIRE_SECS`.
10891089

10901090
For example, you can use `check_sql_auth` to validate username and password.
1091+
`password` argument is provided only when new connections are established.
1092+
`check_sql_auth` implementation should gracefully handle missing `password` field to handle change user and re-authentication flows.
1093+
`check_sql_auth` should always return `password` as it used for validation of password provided by user.
1094+
If clear text password can't be obtained, best practice is to return `password` provided as an argument after password validation.
1095+
Only security context is used for change user and re-authentication flows so returned `password` isn't checked in this case.
10911096

10921097
<CodeTabs>
10931098

10941099
```python
10951100
from cube import config
10961101

10971102
@config('check_sql_auth')
1098-
def check_sql_auth(req: dict, user_name: str) -> dict:
1103+
def check_sql_auth(req: dict, user_name: str, password: str) -> dict:
10991104
if user_name == 'my_user':
1105+
if password and password != 'my_password':
1106+
raise Exception('Access denied')
11001107
return {
1101-
'password': 'my_password',
1108+
'password': password,
11021109
'securityContext': {
11031110
'some': 'data'
11041111
}
@@ -1110,10 +1117,13 @@ def check_sql_auth(req: dict, user_name: str) -> dict:
11101117

11111118
```javascript
11121119
module.exports = {
1113-
checkSqlAuth: (req, user_name) => {
1120+
checkSqlAuth: (req, user_name, password) => {
11141121
if (user_name === 'my_user') {
1122+
if (password && password !== 'my_password') {
1123+
throw new Error('Access denied');
1124+
}
11151125
return {
1116-
password: 'my_password',
1126+
password,
11171127
securityContext: {
11181128
some: 'data'
11191129
},

docs/pages/reference/configuration/environment-variables.mdx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -975,6 +975,15 @@ If `true`, then send telemetry to Cube.
975975
| --------------- | ---------------------- | --------------------- |
976976
| `true`, `false` | `true` | `true` |
977977

978+
979+
## `CUBESQL_AUTH_EXPIRE_SECS`
980+
981+
Number of seconds before session's SQL API security context will be invalidated.
982+
983+
| Possible Values | Default in Development | Default in Production |
984+
| --------------- | ---------------------- | --------------------- |
985+
| A valid integer number | `300` | `300` |
986+
978987
## `CUBESTORE_METRICS_FORMAT`
979988

980989
Define which metrics collector format.

packages/cubejs-api-gateway/src/sql-server.ts

Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -47,20 +47,21 @@ export class SQLServer {
4747

4848
const contextByRequest = async (request, session) => {
4949
let userForContext = session.user;
50+
let { securityContext } = session;
5051

5152
if (request.meta.changeUser && request.meta.changeUser !== session.user) {
5253
const canSwitch = session.superuser || await canSwitchSqlUser(session.user, request.meta.changeUser);
5354
if (canSwitch) {
5455
userForContext = request.meta.changeUser;
56+
const current = await checkSqlAuth(request, userForContext, null);
57+
securityContext = current.securityContext;
5558
} else {
5659
throw new Error(
5760
`You cannot change security context via __user from ${session.user} to ${request.meta.changeUser}, because it's not allowed.`
5861
);
5962
}
6063
}
61-
// @todo Store security context in native for session's user, but not for switching
62-
const current = await checkSqlAuth(request, userForContext);
63-
return this.contextByNativeReq(request, current.securityContext, request.id);
64+
return this.contextByNativeReq(request, securityContext, request.id);
6465
};
6566

6667
const canSwitchUserForSession = async (session, user) => session.superuser || canSwitchSqlUser(session.user, user);
@@ -69,19 +70,19 @@ export class SQLServer {
6970
port: options.sqlPort,
7071
pgPort: options.pgSqlPort,
7172
nonce: options.sqlNonce,
72-
checkAuth: async ({ request, user }) => {
73-
const { password, superuser } = await checkSqlAuth(request, user);
73+
checkAuth: async ({ request, user, password }) => {
74+
const { password: returnedPassword, superuser, securityContext, skipPasswordCheck } = await checkSqlAuth(request, user, password);
7475

7576
// Strip securityContext to improve speed deserialization
7677
return {
77-
password,
78+
password: returnedPassword,
7879
superuser: superuser || false,
80+
securityContext,
81+
skipPasswordCheck,
7982
};
8083
},
8184
meta: async ({ request, session }) => {
82-
// @todo Store security context in native
83-
const { securityContext } = await checkSqlAuth(request, session.user);
84-
const context = await this.apiGateway.contextByReq(<any> request, securityContext, request.id);
85+
const context = await this.apiGateway.contextByReq(<any> request, session.securityContext, request.id);
8586

8687
// eslint-disable-next-line no-async-promise-executor
8788
return new Promise(async (resolve, reject) => {
@@ -177,9 +178,7 @@ export class SQLServer {
177178
sqlGenerators: async (paramsJson: string) => {
178179
// TODO get rid of it
179180
const { request, session } = JSON.parse(paramsJson);
180-
// @todo Store security context in native
181-
const { securityContext } = await checkSqlAuth(request, session.user);
182-
const context = await this.apiGateway.contextByReq(<any> request, securityContext, request.id);
181+
const context = await this.apiGateway.contextByReq(<any> request, session.securityContext, request.id);
183182

184183
// eslint-disable-next-line no-async-promise-executor
185184
return new Promise(async (resolve, reject) => {
@@ -200,16 +199,12 @@ export class SQLServer {
200199
}
201200

202201
protected wrapCheckSqlAuthFn(checkSqlAuth: CheckSQLAuthFn): CheckSQLAuthFn {
203-
return async (req, user) => {
204-
const response = await checkSqlAuth(req, user);
205-
if (typeof response !== 'object' || response.password === null) {
202+
return async (req, user, password) => {
203+
const response = await checkSqlAuth(req, user, password);
204+
if (typeof response !== 'object') {
206205
throw new Error('checkSqlAuth must return an object');
207206
}
208207

209-
if (!response.password) {
210-
throw new Error('checkSqlAuth must return an object with password field');
211-
}
212-
213208
return response;
214209
};
215210
}
@@ -255,7 +250,8 @@ export class SQLServer {
255250

256251
return {
257252
password: allowedPassword,
258-
securityContext: {}
253+
securityContext: {},
254+
skipPasswordCheck: getEnv('devMode') && !allowedPassword
259255
};
260256
};
261257
}

packages/cubejs-api-gateway/src/types/auth.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ type CheckAuthFn =
5555
type CheckSQLAuthSuccessResponse = {
5656
password: string | null,
5757
superuser?: boolean,
58-
securityContext?: any
58+
securityContext?: any,
59+
skipPasswordCheck?: boolean,
5960
};
6061

6162
/**
@@ -64,7 +65,7 @@ type CheckSQLAuthSuccessResponse = {
6465
* auth logic.
6566
*/
6667
type CheckSQLAuthFn =
67-
(ctx: any, user: string | null) =>
68+
(ctx: any, user: string | null, password: string | null) =>
6869
Promise<CheckSQLAuthSuccessResponse> |
6970
CheckSQLAuthSuccessResponse;
7071

packages/cubejs-backend-native/js/index.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,17 +24,21 @@ export interface Request<Meta> {
2424

2525
export interface CheckAuthResponse {
2626
password: string | null,
27-
superuser: boolean
27+
superuser: boolean,
28+
securityContext: any,
29+
skipPasswordCheck?: boolean,
2830
}
2931

3032
export interface CheckAuthPayload {
3133
request: Request<undefined>,
32-
user: string | null
34+
user: string | null,
35+
password: string | null,
3336
}
3437

3538
export interface SessionContext {
3639
user: string | null,
3740
superuser: boolean,
41+
securityContext: any,
3842
}
3943

4044
export interface LoadPayload {

packages/cubejs-backend-native/src/auth.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,18 +40,24 @@ pub struct TransportRequest {
4040
struct CheckAuthRequest {
4141
request: TransportRequest,
4242
user: Option<String>,
43+
password: Option<String>,
4344
}
4445

4546
#[derive(Debug, Deserialize)]
4647
struct CheckAuthResponse {
4748
password: Option<String>,
4849
superuser: bool,
50+
#[serde(rename = "securityContext", skip_serializing_if = "Option::is_none")]
51+
security_context: Option<serde_json::Value>,
52+
#[serde(rename = "skipPasswordCheck", skip_serializing_if = "Option::is_none")]
53+
skip_password_check: Option<bool>,
4954
}
5055

5156
#[derive(Debug)]
5257
pub struct NativeAuthContext {
5358
pub user: Option<String>,
5459
pub superuser: bool,
60+
pub security_context: Option<serde_json::Value>,
5561
}
5662

5763
impl AuthContext for NativeAuthContext {
@@ -62,7 +68,11 @@ impl AuthContext for NativeAuthContext {
6268

6369
#[async_trait]
6470
impl SqlAuthService for NodeBridgeAuthService {
65-
async fn authenticate(&self, user: Option<String>) -> Result<AuthenticateResponse, CubeError> {
71+
async fn authenticate(
72+
&self,
73+
user: Option<String>,
74+
password: Option<String>,
75+
) -> Result<AuthenticateResponse, CubeError> {
6676
trace!("[auth] Request ->");
6777

6878
let request_id = Uuid::new_v4().to_string();
@@ -73,6 +83,7 @@ impl SqlAuthService for NodeBridgeAuthService {
7383
meta: None,
7484
},
7585
user: user.clone(),
86+
password: password.clone(),
7687
})?;
7788
let response: CheckAuthResponse = call_js_with_channel_as_callback(
7889
self.channel.clone(),
@@ -86,8 +97,10 @@ impl SqlAuthService for NodeBridgeAuthService {
8697
context: Arc::new(NativeAuthContext {
8798
user,
8899
superuser: response.superuser,
100+
security_context: response.security_context,
89101
}),
90102
password: response.password,
103+
skip_password_check: response.skip_password_check.unwrap_or(false),
91104
})
92105
}
93106
}

packages/cubejs-backend-native/src/transport.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ impl NodeBridgeTransport {
6161
struct SessionContext {
6262
user: Option<String>,
6363
superuser: bool,
64+
#[serde(rename = "securityContext", skip_serializing_if = "Option::is_none")]
65+
security_context: Option<serde_json::Value>,
6466
}
6567

6668
#[derive(Debug, Serialize)]
@@ -113,6 +115,7 @@ impl TransportService for NodeBridgeTransport {
113115
session: SessionContext {
114116
user: native_auth.user.clone(),
115117
superuser: native_auth.superuser,
118+
security_context: native_auth.security_context.clone(),
116119
},
117120
})?;
118121
let response = call_js_with_channel_as_callback::<V1MetaResponse>(
@@ -206,6 +209,7 @@ impl TransportService for NodeBridgeTransport {
206209
session: SessionContext {
207210
user: native_auth.user.clone(),
208211
superuser: native_auth.superuser,
212+
security_context: native_auth.security_context.clone(),
209213
},
210214
sql_query: None,
211215
member_to_alias,
@@ -280,6 +284,7 @@ impl TransportService for NodeBridgeTransport {
280284
session: SessionContext {
281285
user: native_auth.user.clone(),
282286
superuser: native_auth.superuser,
287+
security_context: native_auth.security_context.clone(),
283288
},
284289
sql_query: sql_query.clone().map(|q| (q.sql, q.values)),
285290
member_to_alias: None,
@@ -358,6 +363,7 @@ impl TransportService for NodeBridgeTransport {
358363
session: SessionContext {
359364
user: native_auth.user.clone(),
360365
superuser: native_auth.superuser,
366+
security_context: native_auth.security_context.clone(),
361367
},
362368
member_to_alias: None,
363369
expression_params: None,
@@ -402,6 +408,7 @@ impl TransportService for NodeBridgeTransport {
402408
session: SessionContext {
403409
user: native_auth.user.clone(),
404410
superuser: native_auth.superuser,
411+
security_context: native_auth.security_context.clone(),
405412
},
406413
},
407414
Box::new(|cx, v| match NodeObjSerializer::serialize(&v, cx) {

packages/cubejs-backend-native/test/__snapshots__/jinja.test.ts.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ dump:
247247
`;
248248

249249
exports[`Jinja (new api) render template_error_python.jinja: template_error_python.jinja 1`] = `
250-
[Error: could not render block: Call error: Internal: Python error: Exception: Random Exception
250+
[Error: could not render block: Call error: Python error: Exception: Random Exception
251251
Traceback (most recent call last):
252252
File "jinja-instance.py", line 110, in throw_exception
253253

packages/cubejs-backend-native/test/sql.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ describe('SQLInterface', () => {
3131
expect(session).toEqual({
3232
user: expect.toBeTypeOrNull(String),
3333
superuser: expect.any(Boolean),
34+
securityContext: { foo: 'bar' }
3435
});
3536

3637
// It's just an emulation that ApiGateway returns error
@@ -56,6 +57,7 @@ describe('SQLInterface', () => {
5657
expect(session).toEqual({
5758
user: expect.toBeTypeOrNull(String),
5859
superuser: expect.any(Boolean),
60+
securityContext: { foo: 'bar' }
5961
});
6062

6163
// It's just an emulation that ApiGateway returns error
@@ -98,6 +100,7 @@ describe('SQLInterface', () => {
98100
expect(session).toEqual({
99101
user: expect.toBeTypeOrNull(String),
100102
superuser: expect.any(Boolean),
103+
securityContext: { foo: 'bar' },
101104
});
102105

103106
return metaFixture;
@@ -125,13 +128,15 @@ describe('SQLInterface', () => {
125128
return {
126129
password: 'password_for_allowed_user',
127130
superuser: false,
131+
securityContext: { foo: 'bar' },
128132
};
129133
}
130134

131135
if (user === 'admin') {
132136
return {
133137
password: 'password_for_admin',
134138
superuser: true,
139+
securityContext: { foo: 'admin' },
135140
};
136141
}
137142

@@ -175,6 +180,7 @@ describe('SQLInterface', () => {
175180
meta: null,
176181
},
177182
user: user || null,
183+
password: null,
178184
});
179185
};
180186

@@ -226,6 +232,7 @@ describe('SQLInterface', () => {
226232
meta: null,
227233
},
228234
user: 'allowed_user',
235+
password: null,
229236
});
230237

231238
expect(meta.mock.calls.length).toEqual(1);
@@ -237,6 +244,7 @@ describe('SQLInterface', () => {
237244
session: {
238245
user: 'allowed_user',
239246
superuser: false,
247+
securityContext: { foo: 'bar' },
240248
}
241249
});
242250

packages/cubejs-testing/birdbox-fixtures/postgresql/single/sqlapi.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,13 @@ module.exports = {
1212

1313
return query;
1414
},
15-
checkSqlAuth: async (req, user) => {
15+
checkSqlAuth: async (req, user, password) => {
1616
if (user === 'admin') {
17+
if (password && password !== 'admin_password') {
18+
throw new Error(`Password doesn't match for ${user}`);
19+
}
1720
return {
18-
password: 'admin_password',
21+
password,
1922
superuser: true,
2023
securityContext: {
2124
user: 'admin'

0 commit comments

Comments
 (0)