From f398782091fb0296fe142171bc1ef3c4988c547d Mon Sep 17 00:00:00 2001 From: Andrew Davis Date: Tue, 5 Nov 2024 09:11:27 -0500 Subject: [PATCH] Add support for role query parameters (#328) Co-authored-by: Serge Klochkov <3175289+slvrtrn@users.noreply.github.com> --- examples/README.md | 1 + examples/role.ts | 124 ++++++ .../__tests__/integration/role.test.ts | 359 ++++++++++++++++++ .../__tests__/unit/to_search_params.test.ts | 19 + packages/client-common/src/client.ts | 8 + packages/client-common/src/config.ts | 3 + packages/client-common/src/connection.ts | 1 + packages/client-common/src/utils/url.ts | 12 + .../src/connection/node_base_connection.ts | 3 + .../src/connection/web_connection.ts | 3 + 10 files changed, 533 insertions(+) create mode 100644 examples/role.ts create mode 100644 packages/client-common/__tests__/integration/role.test.ts diff --git a/examples/README.md b/examples/README.md index 225eff8a..b98afa6a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -68,6 +68,7 @@ If something is missing, or you found a mistake in one of these examples, please - [default_format_setting.ts](default_format_setting.ts) - sending queries using `exec` method without a `FORMAT` clause; the default format will be set from the client settings. - [session_id_and_temporary_tables.ts](session_id_and_temporary_tables.ts) - creating a temporary table, which requires a session_id to be passed to the server. - [session_level_commands.ts](session_level_commands.ts) - using SET commands, memorized for the specific session_id. +- [role.ts](role.ts) - using one or more roles without explicit `USE` commands or session IDs ## How to run diff --git a/examples/role.ts b/examples/role.ts new file mode 100644 index 00000000..5ee4770e --- /dev/null +++ b/examples/role.ts @@ -0,0 +1,124 @@ +import type { ClickHouseError } from '@clickhouse/client' +import { createClient } from '@clickhouse/client' // or '@clickhouse/client-web' + +/** + * An example of specifying a role using query parameters + * See https://clickhouse.com/docs/en/interfaces/http#setting-role-with-query-parameters + */ +void (async () => { + const format = 'JSON' + const username = 'role_user' + const password = 'role_user_password' + const table1 = 'table_1' + const table2 = 'table_2' + + // Create 2 tables, a role for each table allowing SELECT, and a user with access to those roles + const defaultClient = createClient() + await createOrReplaceUser(username, password) + const table1Role = await createTableAndGrantAccess(table1, username) + const table2Role = await createTableAndGrantAccess(table2, username) + await defaultClient.close() + + // Create a client using a role that only has permission to query table1 + const client = createClient({ + username, + password, + role: table1Role, + }) + + // Selecting from table1 is allowed using table1Role + let rs = await client.query({ + query: `select count(*) from ${table1}`, + format, + }) + console.log( + `Successfully queried from ${table1} using ${table1Role}. Result: `, + (await rs.json()).data, + ) + + // Selecting from table2 is not allowed using table1Role + await client + .query({ query: `select count(*) from ${table2}`, format }) + .catch((e: ClickHouseError) => { + console.error( + `Failed to qeury from ${table2} due to error with type: ${e.type}. Message: ${e.message}`, + ) + }) + + // Override the client's role to table2Role, allowing a query to table2 + rs = await client.query({ + query: `select count(*) from ${table2}`, + format, + role: table2Role, + }) + console.log( + `Successfully queried from ${table2} using ${table2Role}. Result: `, + (await rs.json()).data, + ) + + // Selecting from table1 is no longer allowed, since table2Role is being used + await client + .query({ + query: `select count(*) from ${table1}`, + format, + role: table2Role, + }) + .catch((e: ClickHouseError) => { + console.error( + `Failed to qeury from ${table1} due to error with type: ${e.type}. Message: ${e.message}`, + ) + }) + + // Multiple roles can be specified to allowed querying from either table + rs = await client.query({ + query: `select count(*) from ${table1}`, + format, + role: [table1Role, table2Role], + }) + console.log( + `Successfully queried from ${table1} using roles: [${table1Role}, ${table2Role}]. Result: `, + (await rs.json()).data, + ) + + rs = await client.query({ + query: `select count(*) from ${table2}`, + format, + role: [table1Role, table2Role], + }) + console.log( + `Successfully queried from ${table2} using roles: [${table1Role}, ${table2Role}]. Result: `, + (await rs.json()).data, + ) + + await client.close() + + async function createOrReplaceUser(username: string, password: string) { + await defaultClient.command({ + query: `CREATE USER OR REPLACE ${username} IDENTIFIED WITH plaintext_password BY '${password}'`, + }) + } + + async function createTableAndGrantAccess( + tableName: string, + username: string, + ) { + const role = `${tableName}_role` + + await defaultClient.command({ + query: ` + CREATE OR REPLACE TABLE ${tableName} + (id UInt32, name String, sku Array(UInt32)) + ENGINE MergeTree() + ORDER BY (id) + `, + }) + + await defaultClient.command({ query: `CREATE ROLE OR REPLACE ${role}` }) + await defaultClient.command({ + query: `GRANT SELECT ON ${tableName} TO ${role}`, + }) + await defaultClient.command({ query: `GRANT ${role} TO ${username}` }) + + return role + } +})() diff --git a/packages/client-common/__tests__/integration/role.test.ts b/packages/client-common/__tests__/integration/role.test.ts new file mode 100644 index 00000000..c73af05d --- /dev/null +++ b/packages/client-common/__tests__/integration/role.test.ts @@ -0,0 +1,359 @@ +import type { ClickHouseClient } from '@clickhouse/client-common' +import { createTestClient, TestEnv, whenOnEnv } from '@test/utils' +import { getTestDatabaseName, guid } from '../utils' +import { createSimpleTable } from '../fixtures/simple_table' +import { assertJsonValues, jsonValues } from '../fixtures/test_data' + +describe('role settings', () => { + let defaultClient: ClickHouseClient + let client: ClickHouseClient + + let database: string + let username: string + let password: string + let roleName1: string + let roleName2: string + + beforeAll(async () => { + defaultClient = createTestClient() + username = `clickhousejs__user_with_roles_${guid()}` + password = `CHJS_${guid()}` + roleName1 = `TEST_ROLE_${guid()}` + roleName2 = `TEST_ROLE_${guid()}` + database = getTestDatabaseName() + + await defaultClient.command({ + query: `CREATE USER ${username} IDENTIFIED WITH sha256_password BY '${password}' DEFAULT DATABASE ${database}`, + }) + await defaultClient.command({ + query: `CREATE ROLE IF NOT EXISTS ${roleName1}`, + }) + await defaultClient.command({ + query: `CREATE ROLE IF NOT EXISTS ${roleName2}`, + }) + await defaultClient.command({ + query: `GRANT ${roleName1}, ${roleName2} TO ${username}`, + }) + await defaultClient.command({ + query: `GRANT INSERT ON ${database}.* TO ${roleName1}`, + }) + await defaultClient.command({ + query: `GRANT CREATE TABLE ON * TO ${roleName1}`, + }) + }) + + afterEach(async () => { + await client.close() + }) + + afterAll(async () => { + await defaultClient.close() + }) + + describe('for queries', () => { + async function queryCurrentRoles(role?: string | Array) { + const rs = await client.query({ + query: 'select currentRoles() as roles', + format: 'JSONEachRow', + role, + }) + + const jsonResults = (await rs.json()) as { roles: string[] }[] + return jsonResults[0].roles + } + + whenOnEnv(TestEnv.LocalSingleNode).it( + 'should use a single role from the client configuration', + async () => { + client = createTestClient({ + username, + password, + role: roleName1, + }) + + const actualRoles = await queryCurrentRoles() + expect(actualRoles).toEqual([roleName1]) + }, + ) + + whenOnEnv(TestEnv.LocalSingleNode).it( + 'should use multiple roles from the client configuration', + async () => { + client = createTestClient({ + username, + password, + role: [roleName1, roleName2], + }) + + const actualRoles = await queryCurrentRoles() + expect(actualRoles.length).toBe(2) + expect(actualRoles).toContain(roleName1) + expect(actualRoles).toContain(roleName2) + }, + ) + + whenOnEnv(TestEnv.LocalSingleNode).it( + 'should use single role from the query options', + async () => { + client = createTestClient({ + username, + password, + role: [roleName1, roleName2], + }) + + const actualRoles = await queryCurrentRoles(roleName2) + expect(actualRoles).toEqual([roleName2]) + }, + ) + + whenOnEnv(TestEnv.LocalSingleNode).it( + 'should use multiple roles from the query options', + async () => { + client = createTestClient({ + username, + password, + }) + + const actualRoles = await queryCurrentRoles([roleName1, roleName2]) + expect(actualRoles.length).toBe(2) + expect(actualRoles).toContain(roleName1) + expect(actualRoles).toContain(roleName2) + }, + ) + }) + + describe('for inserts', () => { + let tableName: string + + beforeEach(async () => { + tableName = `insert_test_${guid()}` + await createSimpleTable(defaultClient, tableName) + }) + + async function tryInsert(role?: string | Array) { + await client.insert({ + table: tableName, + values: jsonValues, + format: 'JSONEachRow', + role, + }) + } + + whenOnEnv(TestEnv.LocalSingleNode).it( + 'should successfully insert when client specifies a role that is allowed to insert', + async () => { + client = createTestClient({ + username, + password, + role: roleName1, + }) + + await tryInsert() + await assertJsonValues(defaultClient, tableName) + }, + ) + + whenOnEnv(TestEnv.LocalSingleNode).it( + 'should successfully insert when client specifies multiple roles and at least one is allowed to insert', + async () => { + client = createTestClient({ + username, + password, + role: [roleName1, roleName2], + }) + + await tryInsert() + await assertJsonValues(defaultClient, tableName) + }, + ) + + whenOnEnv(TestEnv.LocalSingleNode).it( + 'should fail to insert when client specifies a role that is not allowed to insert', + async () => { + client = createTestClient({ + username, + password, + role: roleName2, + }) + + await expectAsync(tryInsert()).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringContaining('Not enough privileges'), + code: '497', + type: 'ACCESS_DENIED', + }), + ) + }, + ) + + whenOnEnv(TestEnv.LocalSingleNode).it( + 'should successfully insert when insert specifies a role that is allowed to insert', + async () => { + client = createTestClient({ + username, + password, + role: roleName2, + }) + + await tryInsert(roleName1) + await assertJsonValues(defaultClient, tableName) + }, + ) + + whenOnEnv(TestEnv.LocalSingleNode).it( + 'should successfully insert when insert specifies multiple roles and at least one is allowed to insert', + async () => { + client = createTestClient({ + username, + password, + role: roleName2, + }) + + await tryInsert([roleName1, roleName2]) + await assertJsonValues(defaultClient, tableName) + }, + ) + + whenOnEnv(TestEnv.LocalSingleNode).it( + 'should fail to insert when insert specifies a role that is not allowed to insert', + async () => { + client = createTestClient({ + username, + password, + role: roleName1, + }) + + await expectAsync(tryInsert(roleName2)).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringContaining('Not enough privileges'), + code: '497', + type: 'ACCESS_DENIED', + }), + ) + }, + ) + }) + + describe('for commands', () => { + let tableName: string + + beforeEach(async () => { + tableName = `command_role_test_${guid()}` + }) + + async function tryCreateTable(role?: string | Array) { + const query = ` + CREATE TABLE ${tableName} + (id UInt64, name String, sku Array(UInt8), timestamp DateTime) + ENGINE = MergeTree() + ORDER BY (id) + ` + await client.command({ query, role }) + } + + async function checkCreatedTable(tableName: string) { + const selectResult = await defaultClient.query({ + query: `SELECT * from system.tables where name = '${tableName}'`, + format: 'JSON', + }) + + const { data, rows } = await selectResult.json<{ name: string }>() + expect(rows).toBe(1) + expect(data[0].name).toBe(tableName) + } + + whenOnEnv(TestEnv.LocalSingleNode).it( + 'should successfully create a table when client specifies a role that is allowed to create tables', + async () => { + client = createTestClient({ + username, + password, + role: roleName1, + }) + + await tryCreateTable() + await checkCreatedTable(tableName) + }, + ) + + whenOnEnv(TestEnv.LocalSingleNode).it( + 'should successfully create table when client specifies multiple roles and at least one is allowed to create tables', + async () => { + client = createTestClient({ + username, + password, + role: [roleName1, roleName2], + }) + + await tryCreateTable() + await checkCreatedTable(tableName) + }, + ) + + whenOnEnv(TestEnv.LocalSingleNode).it( + 'should fail to create a table when client specifies a role that is not allowed to create tables', + async () => { + client = createTestClient({ + username, + password, + role: roleName2, + }) + + await expectAsync(tryCreateTable()).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringContaining('Not enough privileges'), + code: '497', + type: 'ACCESS_DENIED', + }), + ) + }, + ) + + whenOnEnv(TestEnv.LocalSingleNode).it( + 'should successfully create table when command specifies a role that is allowed to create tables', + async () => { + client = createTestClient({ + username, + password, + role: roleName2, + }) + + await tryCreateTable(roleName1) + await checkCreatedTable(tableName) + }, + ) + + whenOnEnv(TestEnv.LocalSingleNode).it( + 'should successfully create table when command specifies multiple roles and at least one is allowed to create tables', + async () => { + client = createTestClient({ + username, + password, + role: roleName2, + }) + + await tryCreateTable([roleName1, roleName2]) + await checkCreatedTable(tableName) + }, + ) + + whenOnEnv(TestEnv.LocalSingleNode).it( + 'should fail to create table when command specifies a role that is not allowed to create tables', + async () => { + client = createTestClient({ + username, + password, + role: roleName1, + }) + + await expectAsync(tryCreateTable(roleName2)).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringContaining('Not enough privileges'), + code: '497', + type: 'ACCESS_DENIED', + }), + ) + }, + ) + }) +}) diff --git a/packages/client-common/__tests__/unit/to_search_params.test.ts b/packages/client-common/__tests__/unit/to_search_params.test.ts index 3f6f43ea..c893eeec 100644 --- a/packages/client-common/__tests__/unit/to_search_params.test.ts +++ b/packages/client-common/__tests__/unit/to_search_params.test.ts @@ -78,6 +78,7 @@ describe('toSearchParams', () => { qaz: 'qux', }, session_id: 'my-session-id', + role: ['my-role-1', 'my-role-2'], query_id: 'my-query-id', query, })! @@ -89,10 +90,28 @@ describe('toSearchParams', () => { ['param_qaz', 'qux'], ['query', 'SELECT * FROM system.query_log'], ['query_id', 'my-query-id'], + ['role', 'my-role-1'], + ['role', 'my-role-2'], ['session_id', 'my-session-id'], ['wait_end_of_query', '1'], ]) }) + + it('should set a single role', async () => { + const query = 'SELECT * FROM system.query_log' + const params = toSearchParams({ + database: 'some_db', + query, + query_id: 'my-query-id', + role: 'single-role', + })! + const result = toSortedArray(params) + expect(result).toEqual([ + ['database', 'some_db'], + ['query', 'SELECT * FROM system.query_log'], + ['role', 'single-role'], + ]) + }) }) function toSortedArray(params: URLSearchParams): [string, string][] { diff --git a/packages/client-common/src/client.ts b/packages/client-common/src/client.ts index d7039b00..83481b12 100644 --- a/packages/client-common/src/client.ts +++ b/packages/client-common/src/client.ts @@ -31,6 +31,10 @@ export interface BaseQueryParams { * If it is not set, {@link BaseClickHouseClientConfigOptions.session_id} will be used. * @default undefined (no override) */ session_id?: string + /** A specific list of roles to use for this query. + * If it is not set, {@link BaseClickHouseClientConfigOptions.roles} will be used. + * @default undefined (no override) */ + role?: string | Array /** When defined, overrides the credentials from the {@link BaseClickHouseClientConfigOptions.username} * and {@link BaseClickHouseClientConfigOptions.password} settings for this particular request. * @default undefined (no override) */ @@ -151,6 +155,7 @@ export class ClickHouseClient { private readonly makeResultSet: MakeResultSet private readonly valuesEncoder: ValuesEncoder private readonly sessionId?: string + private readonly role?: string | Array private readonly logWriter: LogWriter constructor( @@ -168,6 +173,7 @@ export class ClickHouseClient { this.logWriter = this.connectionParams.log_writer this.clientClickHouseSettings = this.connectionParams.clickhouse_settings this.sessionId = config.session_id + this.role = config.role this.connection = config.impl.make_connection( configWithURL, this.connectionParams, @@ -205,6 +211,7 @@ export class ClickHouseClient { message: 'Error while processing the ResultSet.', args: { session_id: queryParams.session_id, + role: queryParams.role, query, query_id, }, @@ -306,6 +313,7 @@ export class ClickHouseClient { abort_signal: params.abort_signal, query_id: params.query_id, session_id: params.session_id ?? this.sessionId, + role: params.role ?? this.role, auth: params.auth, } } diff --git a/packages/client-common/src/config.ts b/packages/client-common/src/config.ts index e29871e3..b2bd013a 100644 --- a/packages/client-common/src/config.ts +++ b/packages/client-common/src/config.ts @@ -64,6 +64,9 @@ export interface BaseClickHouseClientConfigOptions { /** ClickHouse Session id to attach to the outgoing requests. * @default empty string (no session) */ session_id?: string + /** ClickHouse role name(s) to attach to the outgoing requests. + * @default undefined string (no roles) */ + role?: string | Array /** @deprecated since version 1.0.0. Use {@link http_headers} instead.
* Additional HTTP headers to attach to the outgoing requests. * @default empty object */ diff --git a/packages/client-common/src/connection.ts b/packages/client-common/src/connection.ts index 821fe1b1..19090d96 100644 --- a/packages/client-common/src/connection.ts +++ b/packages/client-common/src/connection.ts @@ -33,6 +33,7 @@ export interface ConnBaseQueryParams { session_id?: string query_id?: string auth?: { username: string; password: string } + role?: string | Array } export interface ConnInsertParams extends ConnBaseQueryParams { diff --git a/packages/client-common/src/utils/url.ts b/packages/client-common/src/utils/url.ts index 0963aeb9..690ee625 100644 --- a/packages/client-common/src/utils/url.ts +++ b/packages/client-common/src/utils/url.ts @@ -37,6 +37,7 @@ type ToSearchParamsOptions = { query?: string session_id?: string query_id: string + role?: string | Array } // TODO validate max length of the resulting query @@ -48,6 +49,7 @@ export function toSearchParams({ clickhouse_settings, session_id, query_id, + role, }: ToSearchParamsOptions): URLSearchParams { const params = new URLSearchParams() params.set('query_id', query_id) @@ -78,5 +80,15 @@ export function toSearchParams({ params.set('session_id', session_id) } + if (role) { + if (typeof role === 'string') { + params.set('role', role) + } else if (Array.isArray(role)) { + for (const r of role) { + params.append('role', r) + } + } + } + return params } diff --git a/packages/client-node/src/connection/node_base_connection.ts b/packages/client-node/src/connection/node_base_connection.ts index 5c4b1ec3..d17dfc84 100644 --- a/packages/client-node/src/connection/node_base_connection.ts +++ b/packages/client-node/src/connection/node_base_connection.ts @@ -141,6 +141,7 @@ export abstract class NodeBaseConnection session_id: params.session_id, clickhouse_settings, query_id, + role: params.role, }) const { controller, controllerCleanup } = this.getAbortController(params) // allows to enforce the compression via the settings even if the client instance has it disabled @@ -192,6 +193,7 @@ export abstract class NodeBaseConnection query_params: params.query_params, query: params.query, session_id: params.session_id, + role: params.role, query_id, }) const { controller, controllerCleanup } = this.getAbortController(params) @@ -382,6 +384,7 @@ export abstract class NodeBaseConnection database: this.params.database, query_params: params.query_params, session_id: params.session_id, + role: params.role, clickhouse_settings, query_id, } diff --git a/packages/client-web/src/connection/web_connection.ts b/packages/client-web/src/connection/web_connection.ts index 72c69ea5..493d9e19 100644 --- a/packages/client-web/src/connection/web_connection.ts +++ b/packages/client-web/src/connection/web_connection.ts @@ -50,6 +50,7 @@ export class WebConnection implements Connection { clickhouse_settings, query_params: params.query_params, session_id: params.session_id, + role: params.role, query_id, }) const response = await this.request({ @@ -93,6 +94,7 @@ export class WebConnection implements Connection { query_params: params.query_params, query: params.query, session_id: params.session_id, + role: params.role, query_id, }) const response = await this.request({ @@ -224,6 +226,7 @@ export class WebConnection implements Connection { clickhouse_settings: params.clickhouse_settings, query_params: params.query_params, session_id: params.session_id, + role: params.role, query_id, }) const response = await this.request({