Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add support for role query parameters (#328)
Browse files Browse the repository at this point in the history
Co-authored-by: Serge Klochkov <[email protected]>
pulpdrew and slvrtrn authored Nov 5, 2024

Verified

This commit was signed with the committer’s verified signature.
aj-stein-nist A.J. Stein
1 parent 00af5c2 commit f398782
Showing 10 changed files with 533 additions and 0 deletions.
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -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

124 changes: 124 additions & 0 deletions examples/role.ts
Original file line number Diff line number Diff line change
@@ -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
}
})()
359 changes: 359 additions & 0 deletions packages/client-common/__tests__/integration/role.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>) {
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<string>) {
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<string>) {
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',
}),
)
},
)
})
})
19 changes: 19 additions & 0 deletions packages/client-common/__tests__/unit/to_search_params.test.ts
Original file line number Diff line number Diff line change
@@ -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][] {
8 changes: 8 additions & 0 deletions packages/client-common/src/client.ts
Original file line number Diff line number Diff line change
@@ -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<string>
/** 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<Stream = unknown> {
private readonly makeResultSet: MakeResultSet<Stream>
private readonly valuesEncoder: ValuesEncoder<Stream>
private readonly sessionId?: string
private readonly role?: string | Array<string>
private readonly logWriter: LogWriter

constructor(
@@ -168,6 +173,7 @@ export class ClickHouseClient<Stream = unknown> {
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<Stream = unknown> {
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<Stream = unknown> {
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,
}
}
3 changes: 3 additions & 0 deletions packages/client-common/src/config.ts
Original file line number Diff line number Diff line change
@@ -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<string>
/** @deprecated since version 1.0.0. Use {@link http_headers} instead. <br/>
* Additional HTTP headers to attach to the outgoing requests.
* @default empty object */
1 change: 1 addition & 0 deletions packages/client-common/src/connection.ts
Original file line number Diff line number Diff line change
@@ -33,6 +33,7 @@ export interface ConnBaseQueryParams {
session_id?: string
query_id?: string
auth?: { username: string; password: string }
role?: string | Array<string>
}

export interface ConnInsertParams<Stream> extends ConnBaseQueryParams {
12 changes: 12 additions & 0 deletions packages/client-common/src/utils/url.ts
Original file line number Diff line number Diff line change
@@ -37,6 +37,7 @@ type ToSearchParamsOptions = {
query?: string
session_id?: string
query_id: string
role?: string | Array<string>
}

// 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
}
3 changes: 3 additions & 0 deletions packages/client-node/src/connection/node_base_connection.ts
Original file line number Diff line number Diff line change
@@ -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,
}
3 changes: 3 additions & 0 deletions packages/client-web/src/connection/web_connection.ts
Original file line number Diff line number Diff line change
@@ -50,6 +50,7 @@ export class WebConnection implements Connection<ReadableStream> {
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<ReadableStream> {
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<ReadableStream> {
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({

0 comments on commit f398782

Please sign in to comment.