Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 10 additions & 10 deletions src/tools/auth0/handlers/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
duplicateItems,
obfuscateSensitiveValues,
stripObfuscatedFieldsFromPayload,
validateNoUnresolvedPlaceholders,
stripUnresolvedPlaceholders,
detectInsufficientScopeError,
} from '../../utils';
import log from '../../../logger';
Expand Down Expand Up @@ -457,12 +457,12 @@ export default class APIHandler {
const updateFN = this.getClientFN(this.functions.update);
const updatePayload = (() => {
const data = stripFields({ ...updateItem }, this.stripUpdateFields);
const stripped = stripObfuscatedFieldsFromPayload(
const obfuscatedStripped = stripObfuscatedFieldsFromPayload(
data,
this.sensitiveFieldsToObfuscate
);
return validateNoUnresolvedPlaceholders(
stripped as Asset,
return stripUnresolvedPlaceholders(
obfuscatedStripped as Asset,
this.type,
this.objString(updateItem)
);
Expand All @@ -488,12 +488,12 @@ export default class APIHandler {
const createFunction = this.getClientFN(this.functions.create);
const createPayload = (() => {
const strippedPayload = stripFields(createItem, this.stripCreateFields);
const stripped = stripObfuscatedFieldsFromPayload(
const obfuscatedStripped = stripObfuscatedFieldsFromPayload(
strippedPayload,
this.sensitiveFieldsToObfuscate
);
return validateNoUnresolvedPlaceholders(
stripped as Asset,
return stripUnresolvedPlaceholders(
obfuscatedStripped as Asset,
this.type,
this.objString(createItem)
);
Expand Down Expand Up @@ -521,12 +521,12 @@ export default class APIHandler {
const updateFN = this.getClientFN(this.functions.update);
const updatePayload = (() => {
const data = stripFields({ ...updateItem }, this.stripUpdateFields);
const stripped = stripObfuscatedFieldsFromPayload(
const obfuscatedStripped = stripObfuscatedFieldsFromPayload(
data,
this.sensitiveFieldsToObfuscate
);
return validateNoUnresolvedPlaceholders(
stripped as Asset,
return stripUnresolvedPlaceholders(
obfuscatedStripped as Asset,
this.type,
this.objString(updateItem)
);
Expand Down
27 changes: 27 additions & 0 deletions src/tools/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,33 @@ export const stripObfuscatedFieldsFromPayload = (
return newAsset;
};

// Strips unresolved ##...## / @@...@@ placeholders before sending to Auth0.
// Keyword replacement runs first; any remaining placeholder means no mapping was
// provided. The field is dropped so Auth0 preserves its existing value.
export const stripUnresolvedPlaceholders = (
data: Asset | null,
resourceType: string,
resourceName: string
): Asset | null => {
if (data === null) return data;

const unresolved = collectUnresolvedPlaceholders(data);
if (unresolved.length === 0) return data;

const newAsset = { ...data };
unresolved.forEach(({ path, value }) => {
log.warn(
`Stripping unresolved placeholder for ${resourceType} "${resourceName}" field "${path}": ${value}. ` +
`To use this value, define ${value.replace(
/^##|##$|^@@|@@$/g,
''
)} in AUTH0_KEYWORD_REPLACE_MAPPINGS. The existing value on Auth0 will be preserved.`
);
dotProp.delete(newAsset, path);
});
return newAsset;
};

export const detectInsufficientScopeError = async <T>(
fn: Function
): Promise<
Expand Down
117 changes: 87 additions & 30 deletions test/tools/auth0/handlers/default.tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,58 +177,115 @@ describe('#default handler', () => {
expect(didCreateFunctionGetCalled).to.equal(true);
});

it('should throw when creating an asset with an unresolved placeholder', async () => {
it('should strip all unresolved placeholders and not throw when creating', async () => {
let capturedPayload: object | null = null;

const handler = new mockHandler({
client: mockApiClient,
config,
type: mockAssetType,
functions: {
//@ts-ignore
create: async (payload) => payload,
create: async (payload) => {
capturedPayload = payload;
return payload;
},
},
});

await expect(
handler.processChanges({} as Assets, {
del: [],
update: [],
conflicts: [],
create: [
{
id: 'some-id',
unresolved_field: '##UNRESOLVED_PLACEHOLDER##',
resolved_field: 'a-real-value',
await handler.processChanges({} as Assets, {
del: [],
update: [],
conflicts: [],
create: [
{
id: 'some-id',
unresolved_field: '##UNRESOLVED_PLACEHOLDER##',
resolved_field: 'a-real-value',
},
],
});

expect(capturedPayload).to.deep.equal({
id: 'some-id',
resolved_field: 'a-real-value',
});
});

it('should strip all unresolved placeholders and not throw when updating', async () => {
let capturedPayload: object | null = null;

const handler = new mockHandler({
client: mockApiClient,
config,
type: mockAssetType,
functions: {
//@ts-ignore
update: async (_id, payload) => {
capturedPayload = payload;
return payload;
},
},
});

await handler.processChanges({} as Assets, {
del: [],
create: [],
conflicts: [],
update: [
{
id: 'foo',
options: {
client_secret: '##CONNECTIONS_WAAD_SECRET##',
client_id: 'real-client-id',
},
],
})
).to.be.rejectedWith(/Unresolved placeholder/);
non_sensitive_property: 'regular value',
},
],
});

expect(capturedPayload).to.deep.equal({
options: { client_id: 'real-client-id' },
non_sensitive_property: 'regular value',
});
});

it('should throw when updating an asset with an unresolved placeholder', async () => {
it('should strip all unresolved placeholders and not throw when processing conflicts', async () => {
let capturedPayload: object | null = null;

const handler = new mockHandler({
client: mockApiClient,
config,
type: mockAssetType,
functions: {
//@ts-ignore
update: async (_identifiers, payload) => payload,
update: async (_id, payload) => {
capturedPayload = payload;
return payload;
},
},
});

await expect(
handler.processChanges({} as Assets, {
del: [],
create: [],
conflicts: [],
update: [
{
id: 'foo',
secret: '##UNRESOLVED_SECRET##',
non_sensitive_property: 'regular value',
await handler.processChanges({} as Assets, {
del: [],
create: [],
update: [],
conflicts: [
{
id: 'foo',
options: {
client_secret: '##CONNECTIONS_WAAD_SECRET##',
client_id: 'real-client-id',
},
],
})
).to.be.rejectedWith(/Unresolved placeholder/);
non_sensitive_property: 'regular value',
},
],
});

expect(capturedPayload).to.deep.equal({
options: { client_id: 'real-client-id' },
non_sensitive_property: 'regular value',
});
});

describe('AUTH0_IGNORE_DRY_RUN_FIELDS', () => {
Expand Down
75 changes: 75 additions & 0 deletions test/tools/utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,81 @@ describe('#filterExcluded', () => {
});
});
});

describe('#stripUnresolvedPlaceholders', () => {
it('should strip a nested field containing an unresolved ##...## placeholder', () => {
const asset = {
id: 'con-1',
name: 'my-waad-connection',
options: {
client_id: 'real-client-id',
client_secret: '##CONNECTIONS_WAAD_SECRET##',
domain: 'example.onmicrosoft.com',
},
};

const result = utils.stripUnresolvedPlaceholders(asset, 'connections', 'my-waad-connection');
expect(result).to.deep.equal({
id: 'con-1',
name: 'my-waad-connection',
options: {
client_id: 'real-client-id',
domain: 'example.onmicrosoft.com',
},
});
});

it('should strip a field containing an unresolved @@...@@ placeholder', () => {
const asset = {
id: 'con-2',
options: {
client_secret: '@@CONNECTIONS_WAAD_SECRET@@',
client_id: 'abc',
},
};

const result = utils.stripUnresolvedPlaceholders(asset, 'connections', 'con-2');
expect(result).to.deep.equal({
id: 'con-2',
options: { client_id: 'abc' },
});
});

it('should strip ALL unresolved placeholders across all fields', () => {
const asset = {
id: 'con-3',
options: {
client_secret: '##CONNECTIONS_OIDC_SECRET##',
api_key: '##SOME_API_KEY##',
domain: 'real-domain.com',
},
};

const result = utils.stripUnresolvedPlaceholders(asset, 'connections', 'con-3');
expect(result).to.deep.equal({
id: 'con-3',
options: { domain: 'real-domain.com' },
});
});

it('should not strip a field that has a resolved (real) value', () => {
const asset = {
id: 'con-4',
options: {
client_secret: 'the-real-secret',
client_id: 'abc',
},
};

const result = utils.stripUnresolvedPlaceholders(asset, 'connections', 'con-4');
expect(result).to.deep.equal(asset);
});

it('should return null when input is null', () => {
const result = utils.stripUnresolvedPlaceholders(null, 'connections', 'con-5');
expect(result).to.be.null;
});
});
});

describe('#detectInsufficientScopeError', () => {
Expand Down