Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: obfuscated precomputed assignments #164

Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
a1cf3bf
make getMD5HashWithSalt
sameerank Dec 9, 2024
22538e1
add tests
sameerank Dec 9, 2024
7cd544c
Merge branch 'main' into sameeran/ff-3682-use-salt-in-flag-key-hash-t…
sameerank Dec 9, 2024
1ff6d8b
Merge branch 'main' into sameeran/ff-3682-use-salt-in-flag-key-hash-t…
sameerank Dec 12, 2024
b8a2d9d
Export saltedHasher
sameerank Dec 12, 2024
567e66d
Fix tests
sameerank Dec 12, 2024
c8f5171
Alter test to use obfuscated file
sameerank Dec 12, 2024
c0288fe
Merge branch 'main' into sameeran/ff-3682-use-salt-in-flag-key-hash-t…
sameerank Dec 14, 2024
ed3f0b3
Change branch name for test data
sameerank Dec 14, 2024
47333ec
Get all the tests to pass
sameerank Dec 15, 2024
a3ed52a
Make more obvious that the salt was decoded
sameerank Dec 15, 2024
25c6f8c
Switch to using appendBinary for the salt
sameerank Dec 16, 2024
a169e29
Clean up
sameerank Dec 16, 2024
d366925
Include salt in convenience method for setting precomputed flag store
sameerank Dec 16, 2024
8fe80dc
Add a helper to convert context attributes to subject attributes
sameerank Dec 16, 2024
b1048a3
Change default to isObfuscated since we expect the precomputed api to…
sameerank Dec 16, 2024
b81175f
v4.7.1-alpha.0
sameerank Dec 16, 2024
9f36640
Revert "v4.7.1-alpha.0"
sameerank Dec 16, 2024
aee17ce
v4.7.0-alpha.0
sameerank Dec 16, 2024
67e9446
Switch to initializing the client with an options object
sameerank Dec 16, 2024
ed8027d
Make response data not optional
sameerank Dec 16, 2024
4e8aa63
precomputedFlag variable casing
sameerank Dec 16, 2024
6040e96
update hashing
typotter Dec 17, 2024
dd6b1e9
fix lint
leoromanovsky Dec 17, 2024
b178a6c
handoff and address comments
typotter Dec 18, 2024
749e8c1
Merge branch 'main' into sameeran/ff-3682-use-salt-in-flag-key-hash-t…
typotter Dec 18, 2024
e99306d
bump version
typotter Dec 18, 2024
f036edc
Merge branch 'main' into sameeran/ff-3682-use-salt-in-flag-key-hash-t…
sameerank Jan 6, 2025
173b3a3
Inf is a numeric attribute too
sameerank Jan 7, 2025
d43e9be
Remove unnecessary public methods
sameerank Jan 8, 2025
bba9863
Remove more unnecessary functions
sameerank Jan 8, 2025
0d1f341
Add to exported interfaces
sameerank Jan 8, 2025
bb03341
Update src/interfaces.ts
sameerank Jan 8, 2025
ad86727
Update src/attributes.ts attributes is ContextAttributes
sameerank Jan 8, 2025
b15f488
Remove redundant 'subjectAttributes as ContextAttributes'
sameerank Jan 8, 2025
3a322a7
Also print error if store is missing salt
sameerank Jan 8, 2025
928f58d
Remove buildContextAttributes
sameerank Jan 8, 2025
5241bf7
v4.8.0-alpha.0
sameerank Jan 8, 2025
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
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ test-data:
mkdir -p $(tempDir)
git clone -b ${branchName} --depth 1 --single-branch ${githubRepoLink} ${gitDataDir}
cp -r ${gitDataDir}ufc ${testDataDir}
cp -r ${gitDataDir}configuration-wire ${testDataDir}
sameerank marked this conversation as resolved.
Show resolved Hide resolved
rm -rf ${tempDir}

## prepare
Expand Down
98 changes: 30 additions & 68 deletions src/client/eppo-precomputed-client.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import * as td from 'testdouble';

import {
MOCK_PRECOMPUTED_WIRE_FILE,
readMockConfigurationWireResponse,
} from '../../test/testHelpers';
import ApiEndpoints from '../api-endpoints';
import { IAssignmentLogger } from '../assignment-logger';
import { IPrecomputedConfigurationResponse } from '../configuration';
import { IConfigurationStore } from '../configuration-store/configuration-store';
import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store';
import { DEFAULT_POLL_INTERVAL_MS, MAX_EVENT_QUEUE_SIZE, POLL_JITTER_PCT } from '../constants';
Expand All @@ -15,61 +20,19 @@ import EppoPrecomputedClient, {
} from './eppo-precomputed-client';

describe('EppoPrecomputedClient E2E test', () => {
const precomputedFlags = {
createdAt: '2024-11-18T14:23:39.456Z',
format: 'PRECOMPUTED',
environment: {
name: 'Test',
},
flags: {
'string-flag': {
allocationKey: 'allocation-123',
variationKey: 'variation-123',
variationType: 'STRING',
variationValue: 'red',
extraLogging: {},
doLog: true,
},
'boolean-flag': {
allocationKey: 'allocation-124',
variationKey: 'variation-124',
variationType: 'BOOLEAN',
variationValue: true,
extraLogging: {},
doLog: true,
},
'integer-flag': {
allocationKey: 'allocation-125',
variationKey: 'variation-125',
variationType: 'INTEGER',
variationValue: 42,
extraLogging: {},
doLog: true,
},
'numeric-flag': {
allocationKey: 'allocation-126',
variationKey: 'variation-126',
variationType: 'NUMERIC',
variationValue: 3.14,
extraLogging: {},
doLog: true,
},
'json-flag': {
allocationKey: 'allocation-127',
variationKey: 'variation-127',
variationType: 'JSON',
variationValue: '{"key": "value", "number": 123}',
extraLogging: {},
doLog: true,
},
},
}; // TODO: readMockPrecomputedFlagsResponse(MOCK_PRECOMPUTED_FLAGS_RESPONSE_FILE);
const precomputedConfigurationWire = readMockConfigurationWireResponse(
MOCK_PRECOMPUTED_WIRE_FILE,
);
const unparsedPrecomputedResponse = JSON.parse(precomputedConfigurationWire).precomputed.response;
const precomputedResponse: IPrecomputedConfigurationResponse = JSON.parse(
unparsedPrecomputedResponse,
);
sameerank marked this conversation as resolved.
Show resolved Hide resolved

global.fetch = jest.fn(() => {
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(precomputedFlags),
json: () => Promise.resolve(precomputedResponse),
});
}) as jest.Mock;
const storage = new MemoryOnlyConfigurationStore<PrecomputedFlag>();
Expand Down Expand Up @@ -382,7 +345,7 @@ describe('EppoPrecomputedClient E2E test', () => {
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(precomputedFlags),
json: () => Promise.resolve(precomputedResponse),
});
}) as jest.Mock;
});
Expand Down Expand Up @@ -431,7 +394,7 @@ describe('EppoPrecomputedClient E2E test', () => {
});

it('Fetches initial configuration with parameters in constructor', async () => {
client = new EppoPrecomputedClient(precomputedFlagStore);
client = new EppoPrecomputedClient(precomputedFlagStore, true);
client.setSubjectAndPrecomputedFlagsRequestParameters(requestParameters);
// no configuration loaded
let variation = client.getStringAssignment(precomputedFlagKey, 'default');
Expand All @@ -443,7 +406,7 @@ describe('EppoPrecomputedClient E2E test', () => {
});

it('Fetches initial configuration with parameters provided later', async () => {
client = new EppoPrecomputedClient(precomputedFlagStore);
client = new EppoPrecomputedClient(precomputedFlagStore, true);
client.setSubjectAndPrecomputedFlagsRequestParameters(requestParameters);
// no configuration loaded
let variation = client.getStringAssignment(precomputedFlagKey, 'default');
Expand All @@ -464,7 +427,7 @@ describe('EppoPrecomputedClient E2E test', () => {
}
}

client = new EppoPrecomputedClient(new MockStore());
client = new EppoPrecomputedClient(new MockStore(), true);
client.setSubjectAndPrecomputedFlagsRequestParameters({
...requestParameters,
pollAfterSuccessfulInitialization: true,
Expand Down Expand Up @@ -509,7 +472,7 @@ describe('EppoPrecomputedClient E2E test', () => {
let client: EppoPrecomputedClient;

beforeEach(async () => {
client = new EppoPrecomputedClient(storage);
client = new EppoPrecomputedClient(storage, true);
client.setSubjectAndPrecomputedFlagsRequestParameters(requestParameters);
await client.fetchPrecomputedFlags();
});
Expand Down Expand Up @@ -570,7 +533,7 @@ describe('EppoPrecomputedClient E2E test', () => {
ok: true,
status: 200,
json: () => {
return precomputedFlags;
return precomputedResponse;
},
});
}
Expand All @@ -581,7 +544,7 @@ describe('EppoPrecomputedClient E2E test', () => {
...requestParameters,
pollAfterSuccessfulInitialization,
};
client = new EppoPrecomputedClient(precomputedFlagStore);
client = new EppoPrecomputedClient(precomputedFlagStore, true);
client.setSubjectAndPrecomputedFlagsRequestParameters(requestParameters);
// no configuration loaded
let variation = client.getStringAssignment(precomputedFlagKey, 'default');
Expand Down Expand Up @@ -629,7 +592,7 @@ describe('EppoPrecomputedClient E2E test', () => {
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(precomputedFlags),
json: () => Promise.resolve(precomputedResponse),
} as Response);
}
});
Expand All @@ -646,7 +609,7 @@ describe('EppoPrecomputedClient E2E test', () => {
throwOnFailedInitialization,
pollAfterFailedInitialization,
};
client = new EppoPrecomputedClient(precomputedFlagStore);
client = new EppoPrecomputedClient(precomputedFlagStore, true);
client.setSubjectAndPrecomputedFlagsRequestParameters(requestParameters);
// no configuration loaded
expect(client.getStringAssignment(precomputedFlagKey, 'default')).toBe('default');
Expand All @@ -673,29 +636,28 @@ describe('EppoPrecomputedClient E2E test', () => {
});

describe('Obfuscated precomputed flags', () => {
let client: EppoPrecomputedClient;
it('returns decoded variation value', () => {
const salt = 'sodium-chloride';
const saltedAndHashedFlagKey = getMD5Hash(precomputedFlagKey, salt);

beforeAll(() => {
storage.setEntries({
[getMD5Hash(precomputedFlagKey)]: {
[saltedAndHashedFlagKey]: {
...mockPrecomputedFlag,
allocationKey: encodeBase64(mockPrecomputedFlag.allocationKey),
variationKey: encodeBase64(mockPrecomputedFlag.variationKey),
variationValue: encodeBase64(mockPrecomputedFlag.variationValue),
extraLogging: {},
},
});
client = new EppoPrecomputedClient(storage, true);
});

afterAll(() => {
td.reset();
});
const client = new EppoPrecomputedClient(storage, true);
client.setDecodedFlagKeySalt(salt);

it('returns decoded variation value', () => {
expect(client.getStringAssignment(precomputedFlagKey, 'default')).toBe(
mockPrecomputedFlag.variationValue,
);

td.reset();
});
});

Expand Down
74 changes: 46 additions & 28 deletions src/client/eppo-precomputed-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,16 @@ import {
import { decodePrecomputedFlag } from '../decoding';
import { FlagEvaluationWithoutDetails } from '../evaluator';
import FetchHttpClient from '../http-client';
import { PrecomputedFlag, VariationType } from '../interfaces';
import { DecodedPrecomputedFlag, PrecomputedFlag, VariationType } from '../interfaces';
import { getMD5Hash } from '../obfuscation';
import initPoller, { IPoller } from '../poller';
import PrecomputedRequestor from '../precomputed-requestor';
import { Attributes } from '../types';
import { validateNotBlank } from '../validation';
import { LIB_VERSION } from '../version';

import { checkTypeMatch } from './eppo-client';

export type PrecomputedFlagsRequestParameters = {
apiKey: string;
sdkVersion: string;
Expand Down Expand Up @@ -51,32 +53,36 @@ export default class EppoPrecomputedClient {
private precomputedFlagsRequestParameters?: PrecomputedFlagsRequestParameters;
private subjectKey?: string;
private subjectAttributes?: Attributes;
private decodedFlagKeySalt = '';

constructor(
private precomputedFlagStore: IConfigurationStore<PrecomputedFlag>,
private isObfuscated = false,
) {}

public setPrecomputedFlagsRequestParameters(
precomputedFlagsRequestParameters: PrecomputedFlagsRequestParameters,
) {
this.precomputedFlagsRequestParameters = precomputedFlagsRequestParameters;
public setIsObfuscated(isObfuscated: boolean) {
rasendubi marked this conversation as resolved.
Show resolved Hide resolved
this.isObfuscated = isObfuscated;
}

public setSubjectAndPrecomputedFlagsRequestParameters(
precomputedFlagsRequestParameters: PrecomputedFlagsRequestParameters,
) {
this.setPrecomputedFlagsRequestParameters(precomputedFlagsRequestParameters);
this.subjectKey = precomputedFlagsRequestParameters.precompute.subjectKey;
this.subjectAttributes = precomputedFlagsRequestParameters.precompute.subjectAttributes;
public setDecodedFlagKeySalt(salt: string) {
this.decodedFlagKeySalt = salt;
}
// Individual methods for single responsibility
public setPrecomputedFlagsRequestParameters(parameters: PrecomputedFlagsRequestParameters) {
this.precomputedFlagsRequestParameters = parameters;
}

public setPrecomputedFlagStore(precomputedFlagStore: IConfigurationStore<PrecomputedFlag>) {
this.precomputedFlagStore = precomputedFlagStore;
public setSubjectData(subjectKey: string, subjectAttributes: Attributes) {
this.subjectKey = subjectKey;
this.subjectAttributes = subjectAttributes;
}

public setIsObfuscated(isObfuscated: boolean) {
this.isObfuscated = isObfuscated;
Comment on lines -78 to -79
Copy link
Member

Choose a reason for hiding this comment

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

before we added the ability to parse whether a configuration is obfuscated from the configuration, when users needed to bootstrap the SDK in offline mode they need to be able to set this. the canonical example is the JS browser client (which by default is in obfuscated mode) getting a configuration from a nodejs server (which is not obfuscated).

that being said, I know there are customers using this method. removing this public method will require a major version bump please.

overall this is a more elegant solution to receive the obfuscation status from the configuration - users will need to upgrade both their nodejs and js browser SDKs to make sure that metadata is included.

Copy link
Collaborator

Choose a reason for hiding this comment

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

that being said, I know there are customers using this method. removing this public method will require a major version bump please.

This is a method on a PrecomputedEppoClient, not the regular one

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@rasendubi What is the purpose of providing this obfuscated boolean field in the response (https://github.com/Eppo-exp/eppo-multiplatform/blob/5aadb2ed0572a96fbb999066c0191e949abd20f5/eppo_core/src/precomputed.rs#L83) if we're designing the SDKs to always assume that is it true?

Copy link
Collaborator

Choose a reason for hiding this comment

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

if we're designing the SDKs to always assume that is it true?

That may change in the future. The flag is here to facilitate that change

// Convenience method that combines both since they are expected to be set together
public setSubjectAndPrecomputedFlagsRequestParameters(
parameters: PrecomputedFlagsRequestParameters,
) {
this.setPrecomputedFlagsRequestParameters(parameters);
this.setSubjectData(parameters.precompute.subjectKey, parameters.precompute.subjectAttributes);
}

public async fetchPrecomputedFlags() {
Expand Down Expand Up @@ -120,6 +126,16 @@ export default class EppoPrecomputedClient {
subjectAttributes,
);

// A callback to capture the salt and subject information
precomputedRequestor.onPrecomputedResponse = (responseData) => {
if (responseData.decodedSalt) {
this.setDecodedFlagKeySalt(responseData.decodedSalt);
}
if (responseData.subjectKey && responseData.subjectAttributes) {
this.setSubjectData(responseData.subjectKey, responseData.subjectAttributes);
}
};

const pollingCallback = async () => {
if (await this.precomputedFlagStore.isExpired()) {
return precomputedRequestor.fetchAndStorePrecomputedFlags();
Expand All @@ -144,17 +160,19 @@ export default class EppoPrecomputedClient {
}
}

public setPrecomputedFlagStore(store: IConfigurationStore<PrecomputedFlag>) {
this.requestPoller?.stop();
this.precomputedFlagStore = store;
}

// Convenience method that combines both since they are expected to be set together
public setSubjectAndPrecomputedFlagStore(
subjectKey: string,
subjectAttributes: Attributes,
precomputedFlagStore: IConfigurationStore<PrecomputedFlag>,
) {
// Save the new subject data and precomputed flag store together because they are related
// Stop any polling process if it exists from previous subject data to protect consistency
this.requestPoller?.stop();
this.setPrecomputedFlagStore(precomputedFlagStore);
this.subjectKey = subjectKey;
this.subjectAttributes = subjectAttributes;
this.setSubjectData(subjectKey, subjectAttributes);
}

private getPrecomputedAssignment<T>(
Expand All @@ -172,11 +190,10 @@ export default class EppoPrecomputedClient {
return defaultValue;
}

// Check variation type
if (preComputedFlag.variationType !== expectedType) {
logger.error(
`[Eppo SDK] Type mismatch: expected ${expectedType} but flag ${flagKey} has type ${preComputedFlag.variationType}`,
);
// Add type checking before proceeding
if (!checkTypeMatch(expectedType, preComputedFlag.variationType)) {
const errorMessage = `[Eppo SDK] Type mismatch: expected ${expectedType} but flag ${flagKey} has type ${preComputedFlag.variationType}`;
logger.error(errorMessage);
return defaultValue;
}

Expand Down Expand Up @@ -274,15 +291,16 @@ export default class EppoPrecomputedClient {
);
}

private getPrecomputedFlag(flagKey: string): PrecomputedFlag | null {
private getPrecomputedFlag(flagKey: string): DecodedPrecomputedFlag | null {
return this.isObfuscated
? this.getObfuscatedFlag(flagKey)
: this.precomputedFlagStore.get(flagKey);
}

private getObfuscatedFlag(flagKey: string): PrecomputedFlag | null {
private getObfuscatedFlag(flagKey: string): DecodedPrecomputedFlag | null {
const saltedAndHashedFlagKey = getMD5Hash(flagKey, this.decodedFlagKeySalt);
sameerank marked this conversation as resolved.
Show resolved Hide resolved
const precomputedFlag: PrecomputedFlag | null = this.precomputedFlagStore.get(
getMD5Hash(flagKey),
saltedAndHashedFlagKey,
) as PrecomputedFlag;
return precomputedFlag ? decodePrecomputedFlag(precomputedFlag) : null;
}
Expand Down
5 changes: 3 additions & 2 deletions src/decoding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
Shard,
ObfuscatedSplit,
PrecomputedFlag,
DecodedPrecomputedFlag,
} from './interfaces';
import { decodeBase64 } from './obfuscation';

Expand Down Expand Up @@ -78,12 +79,12 @@ export function decodeObject(obj: Record<string, string>): Record<string, string
);
}

export function decodePrecomputedFlag(precomputedFlag: PrecomputedFlag): PrecomputedFlag {
export function decodePrecomputedFlag(precomputedFlag: PrecomputedFlag): DecodedPrecomputedFlag {
return {
...precomputedFlag,
allocationKey: decodeBase64(precomputedFlag.allocationKey),
variationKey: decodeBase64(precomputedFlag.variationKey),
variationValue: decodeBase64(precomputedFlag.variationValue),
variationValue: decodeValue(precomputedFlag.variationValue, precomputedFlag.variationType),
sameerank marked this conversation as resolved.
Show resolved Hide resolved
extraLogging: decodeObject(precomputedFlag.extraLogging),
};
}
Loading
Loading