Skip to content

Commit

Permalink
http basic auth built in (#921)
Browse files Browse the repository at this point in the history
* cluster auth settings in cluster configuration

* compose request decorator with cluster auth headers

* update docs

* simplify auth headers merging

* lift returned value to Promise to simplify

* missing tests

* move docs

* simplify test construction
  • Loading branch information
adrianmroz-allegro authored Nov 8, 2022
1 parent 7b0a3ca commit f680a95
Show file tree
Hide file tree
Showing 9 changed files with 185 additions and 55 deletions.
File renamed without changes
20 changes: 19 additions & 1 deletion docs/configuration-cluster.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,25 @@ The url address (http[s]://hostname[:port]) of the cluster. If no port, 80 is as

The host (hostname:port) of the cluster, http protocol is assumed. Deprecated, use **url** field

**auth**

The cluster authorization strategy.

* Http Basic authorization

Strategy will add `Authorization` header to each request to cluster and encode passed username and password with base64.

```yaml
auth:
type: "http-basic"
username: Aladdin
password: OpenSesame
```
This would result in all Druid request having added headers
![](assets/images/basic-auth-headers.png)
**version** (string)
The explicit version to use for this cluster.
Expand Down Expand Up @@ -169,7 +188,6 @@ This will put additional load on the data store but will ensure that dimension a

How often should source schema be reloaded in ms. Default value of 0 disables periodical source refresh.


### Druid specific properties

**introspectionStrategy** ("segment-metadata-fallback" \| "segment-metadata-only" \| "datasource-get"), default: "segment-metadata-fallback"
Expand Down
5 changes: 3 additions & 2 deletions docs/example/request-decoration/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ clusters:
requestDecorator:
path: './druid-request-decorator.js'
options:
username: Aladdin
password: OpenSesame
base: Pancakes
extras:
- Blueberries

18 changes: 5 additions & 13 deletions docs/example/request-decoration/druid-request-decorator.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,13 @@ exports.version = 1;
// * options: the options field from the requestDecorator property
// * cluster: Cluster - the cluster object
exports.druidRequestDecoratorFactory = function (logger, params) {
const options = params.options;
const username = options.username; // pretend we store the username and password
const password = options.password; // in the config

if (!username) {
throw new Error("must have username");
}
if (!password) {
throw new Error("must have password");
}
const options = params.options;
const extras = options.extras.join(", ");

logger.log("Decorator init for username: " + username);
const like = `${options.base} with ${extras}`;

const auth = "Basic " + Buffer.from(`${username}:${password}`).toString("base64");
logger.log("Decorator created with options:", { options });

// decoratorRequest: DecoratorRequest - is an object that has the following keys:
// * method: string - the method that is used (POST or GET)
Expand All @@ -27,8 +20,7 @@ exports.druidRequestDecoratorFactory = function (logger, params) {
return function (decoratorRequest) {
const decoration = {
headers: {
"Authorization": auth,
"X-I-Like": "Koalas",
"X-I-Like": like,
},
};

Expand Down
19 changes: 8 additions & 11 deletions docs/extending-turnilo.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@ to your `druidRequestDecoratorFactory` under `options` key in second parameter.
druidRequestDecorator:
path: './druid-request-decorator.js'
options:
keyA: valueA
keyB:
- firstElement
- secondElement
base: Pancakes
extras:
- maple-syrup
- blueberries
```

The contract is that your module should export a function `druidRequestDecoratorFactory` that has to return a decorator.
Expand All @@ -46,15 +46,14 @@ exports.version = 1;
exports.druidRequestDecoratorFactory = function (logger, params) {
const options = params.options;
const username = options.username;
const password = options.password;
const extras = options.extras.join(", ");
const auth = "Basic " + Buffer.from(`${username}:${password}`).toString("base64");
const like = `${options.base} with ${extras}`;

return function () {
return {
headers: {
"Authorization": auth
"X-I-Like": auth
},
};
};
Expand All @@ -63,9 +62,7 @@ exports.druidRequestDecoratorFactory = function (logger, params) {

You can find this example with additional comments and example config in the [example](example/request-decoration) folder.

This would result in all Druid requests being tagged as:

![decoration example](example/request-decoration/result.png)
Please note that your object will be merged with [Cluster Authorization](configuration-cluster.md) headers.

## Query decorator

Expand Down
52 changes: 52 additions & 0 deletions src/common/models/cluster-auth/cluster-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* Copyright 2017-2022 Allegro.pl
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { isNil } from "../../utils/general/general";

type ClusterAuthType = "http-basic";

interface BasicHttpClusterAuth {
type: "http-basic";
username: string;
password: string;
}

export type ClusterAuth = BasicHttpClusterAuth;

export interface ClusterAuthJS {
type: ClusterAuthType;
username?: string;
password?: string;
}

export function readClusterAuth(input?: ClusterAuthJS): ClusterAuth | undefined {
if (isNil(input)) return undefined;
switch (input.type) {
case "http-basic": {
const { username, password } = input;
if (isNil(username)) throw new Error("ClusterAuth: username field is required for http-basic auth configuration");
if (isNil(password)) throw new Error("ClusterAuth: password field is required for http-basic auth configuration");
return {
type: "http-basic",
password,
username
};
}
default: {
throw new Error(`ClusterAuth: Unrecognized authorization type: ${input.type}`);
}
}
}
80 changes: 57 additions & 23 deletions src/common/models/cluster/cluster.mocha.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,27 @@ import { expect, use } from "chai";
import equivalent from "../../../client/utils/test-utils/equivalent";
import { RequestDecorator } from "../../../server/utils/request-decorator/request-decorator";
import { RetryOptions } from "../../../server/utils/retry-options/retry-options";
import { ClusterJS, fromConfig } from "./cluster";
import { ClusterAuthJS } from "../cluster-auth/cluster-auth";
import { Cluster, ClusterJS, fromConfig } from "./cluster";

const baseConfig: ClusterJS = {
name: "foobar",
url: "https://foobar.com"
};

function buildCluster(options: Partial<ClusterJS> = {}): Cluster {
return fromConfig({
...baseConfig,
...options
});
}

use(equivalent);

describe("Cluster", () => {
describe("fromConfig", () => {
it("should load default values", () => {
const cluster = fromConfig({
name: "foobar",
url: "http://bazz"
});
const cluster = fromConfig(baseConfig);

expect(cluster).to.be.deep.equal({
name: "foobar",
Expand All @@ -46,31 +56,58 @@ describe("Cluster", () => {
timeout: undefined,
title: "",
type: "druid",
url: "http://bazz",
version: null
url: "https://foobar.com",
version: null,
auth: undefined
});
});

it("should throw with incorrect name type", () => {
expect(() => fromConfig({ name: 1 } as unknown as ClusterJS)).to.throw("must be a string");
expect(() => fromConfig({ ...baseConfig, name: 1 } as unknown as ClusterJS)).to.throw("must be a string");
});

it("should throw with incorrect empty name", () => {
expect(() => fromConfig({ name: "", url: "http://foobar" })).to.throw("empty name");
expect(() => fromConfig({ ...baseConfig, name: "" })).to.throw("empty name");
});

it("should throw with not url safe name", () => {
expect(() => fromConfig({ name: "foobar%bazz#", url: "http://foobar" })).to.throw("is not a URL safe name");
expect(() => fromConfig({ ...baseConfig, name: "foobar%bazz#" })).to.throw("is not a URL safe name");
});

it("should throw with name equal to native", () => {
expect(() => fromConfig({ name: "native", url: "http://foobar" })).to.throw("name can not be 'native'");
expect(() => fromConfig({ ...baseConfig, name: "native" })).to.throw("name can not be 'native'");
});

it("should read auth options", () => {
const cluster = buildCluster({
auth: { type: "http-basic", password: "pass", username: "foobar" }
});

expect(cluster.auth).to.be.deep.equal({
type: "http-basic", password: "pass", username: "foobar"
});
});

it("should throw on unrecognized auth type", () => {
expect(() => buildCluster({
auth: { type: "unknown-method" } as any as ClusterAuthJS
})).to.throw("Unrecognized authorization type: unknown-method");
});

it("should throw on missing username", () => {
expect(() => buildCluster({
auth: { type: "http-basic", username: undefined, password: "pass" }
})).to.throw("username field is required");
});

it("should throw on missing password", () => {
expect(() => buildCluster({
auth: { type: "http-basic", username: "foobar", password: undefined }
})).to.throw("password field is required");
});

it("should read retry options", () => {
const cluster = fromConfig({
name: "foobar",
url: "http://foobar",
const cluster = buildCluster({
retry: {
maxAttempts: 1,
delay: 42
Expand All @@ -94,9 +131,7 @@ describe("Cluster", () => {
});

it("should read request decorator old format", () => {
const cluster = fromConfig({
name: "foobar",
url: "http://foobar",
const cluster = buildCluster({
requestDecorator: "foobar",
decoratorOptions: { bazz: true }
} as unknown as ClusterJS);
Expand All @@ -114,39 +149,38 @@ describe("Cluster", () => {
});

it("should override default values", () => {
const cluster = fromConfig({
const cluster = buildCluster({
guardDataCubes: true,
healthCheckTimeout: 42,
introspectionStrategy: "introspection-introspection",
name: "cluster-name",
sourceListRefreshInterval: 1123,
sourceListRefreshOnLoad: true,
sourceListScan: "auto",
sourceReintrospectInterval: 1432,
sourceReintrospectOnLoad: true,
timeout: 581,
title: "foobar-title",
url: "http://url-bazz",
version: "new-version"
});

expect(cluster).to.be.deep.equal({
guardDataCubes: true,
healthCheckTimeout: 42,
introspectionStrategy: "introspection-introspection",
name: "cluster-name",
name: "foobar",
sourceListRefreshInterval: 1123,
sourceListRefreshOnLoad: true,
sourceListScan: "auto",
sourceReintrospectInterval: 1432,
sourceReintrospectOnLoad: true,
timeout: 581,
title: "foobar-title",
url: "http://url-bazz",
url: "https://foobar.com",
version: "new-version",
type: "druid",
requestDecorator: null,
retry: new RetryOptions()
retry: new RetryOptions(),
auth: undefined
});
});
});
Expand Down
7 changes: 6 additions & 1 deletion src/common/models/cluster/cluster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { URL } from "url";
import { RequestDecorator, RequestDecoratorJS } from "../../../server/utils/request-decorator/request-decorator";
import { RetryOptions, RetryOptionsJS } from "../../../server/utils/retry-options/retry-options";
import { isNil, isTruthy, optionalEnsureOneOf, verifyUrlSafeName } from "../../utils/general/general";
import { ClusterAuth, ClusterAuthJS, readClusterAuth } from "../cluster-auth/cluster-auth";

export type SourceListScan = "disable" | "auto";

Expand All @@ -43,6 +44,7 @@ export interface Cluster {
introspectionStrategy?: string;
requestDecorator?: RequestDecorator;
retry?: RetryOptions;
auth: ClusterAuth;
}

export interface ClusterJS {
Expand All @@ -61,6 +63,7 @@ export interface ClusterJS {
introspectionStrategy?: string;
requestDecorator?: RequestDecoratorJS;
retry?: RetryOptionsJS;
auth?: ClusterAuthJS;
}

export interface SerializedCluster {
Expand Down Expand Up @@ -161,6 +164,7 @@ export function fromConfig(params: ClusterJS): Cluster {
const sourceListRefreshInterval = readInterval(params.sourceListRefreshInterval, DEFAULT_SOURCE_LIST_REFRESH_INTERVAL);
const retry = RetryOptions.fromJS(params.retry);
const requestDecorator = readRequestDecorator(params);
const auth = readClusterAuth(params.auth);

const url = readUrl(params);

Expand All @@ -180,7 +184,8 @@ export function fromConfig(params: ClusterJS): Cluster {
title,
guardDataCubes,
introspectionStrategy,
healthCheckTimeout
healthCheckTimeout,
auth
};
}

Expand Down
Loading

0 comments on commit f680a95

Please sign in to comment.