Skip to content

Commit 452cf6f

Browse files
committed
feat(input_schema): Enable secret objects
1 parent feb1be3 commit 452cf6f

File tree

4 files changed

+217
-24
lines changed

4 files changed

+217
-24
lines changed

packages/input_schema/src/schema.json

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -237,25 +237,58 @@
237237
"objectProperty": {
238238
"title": "Object property",
239239
"type": "object",
240-
"additionalProperties": false,
240+
"additionalProperties": true,
241241
"properties": {
242242
"type": { "enum": ["object"] },
243243
"title": { "type": "string" },
244244
"description": { "type": "string" },
245-
"default": { "type": "object" },
246-
"prefill": { "type": "object" },
247-
"example": { "type": "object" },
248-
"patternKey": { "type": "string" },
249-
"patternValue": { "type": "string" },
250-
"nullable": { "type": "boolean" },
251-
"minProperties": { "type": "integer" },
252-
"maxProperties": { "type": "integer" },
253-
254245
"editor": { "enum": ["json", "proxy", "hidden"] },
255-
"sectionCaption": { "type": "string" },
256-
"sectionDescription": { "type": "string" }
246+
"isSecret": { "type": "boolean" }
257247
},
258-
"required": ["type", "title", "description", "editor"]
248+
"required": ["type", "title", "description", "editor"],
249+
"if": {
250+
"properties": {
251+
"isSecret": {
252+
"not": {
253+
"const": true
254+
}
255+
}
256+
}
257+
},
258+
"then": {
259+
"additionalProperties": false,
260+
"properties": {
261+
"type": { "enum": ["object"] },
262+
"title": { "type": "string" },
263+
"description": { "type": "string" },
264+
"default": { "type": "object" },
265+
"prefill": { "type": "object" },
266+
"example": { "type": "object" },
267+
"patternKey": { "type": "string" },
268+
"patternValue": { "type": "string" },
269+
"nullable": { "type": "boolean" },
270+
"minProperties": { "type": "integer" },
271+
"maxProperties": { "type": "integer" },
272+
"editor": { "enum": ["json", "proxy", "hidden"] },
273+
"sectionCaption": { "type": "string" },
274+
"sectionDescription": { "type": "string" },
275+
"isSecret": { "enum": [false] }
276+
}
277+
},
278+
"else": {
279+
"additionalProperties": false,
280+
"properties": {
281+
"type": { "enum": ["object"] },
282+
"title": { "type": "string" },
283+
"description": { "type": "string" },
284+
"example": { "type": "object" },
285+
"nullable": { "type": "boolean" },
286+
"editor": { "enum": ["json", "hidden"] },
287+
"sectionCaption": { "type": "string" },
288+
"sectionDescription": { "type": "string" },
289+
"isSecret": { "enum": [true] }
290+
}
291+
}
259292
},
260293
"integerProperty": {
261294
"title": "Integer property",

packages/input_secrets/src/input_secrets.ts

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,29 @@ export function getInputSchemaSecretFieldKeys(inputSchema: any): string[] {
2323

2424
/**
2525
* Encrypts input secret value
26+
* Depending on the type of value, it returns either a string (for strings) or an object (for objects) with the `secret` key.
2627
*/
27-
export function encryptInputSecretValue({ value, publicKey }: { value: string, publicKey: KeyObject }): string {
28-
ow(value, ow.string);
28+
export function encryptInputSecretValue<T extends string | object>({ value, publicKey }: { value: T, publicKey: KeyObject }):
29+
T extends string ? string : { secret: string } {
30+
ow(value, ow.any(ow.string, ow.object));
2931
ow(publicKey, ow.object.instanceOf(KeyObject));
3032

31-
const { encryptedValue, encryptedPassword } = publicEncrypt({ value, publicKey });
32-
return `${ENCRYPTED_INPUT_VALUE_PREFIX}:${encryptedPassword}:${encryptedValue}`;
33+
type ResultType = T extends string ? string : { secret: string };
34+
35+
if (typeof value === 'string') {
36+
const { encryptedValue, encryptedPassword } = publicEncrypt({ value, publicKey });
37+
return `${ENCRYPTED_INPUT_VALUE_PREFIX}:${encryptedPassword}:${encryptedValue}` as ResultType;
38+
}
39+
40+
let valueStr: string;
41+
try {
42+
valueStr = JSON.stringify(value);
43+
} catch (err) {
44+
throw new Error(`The input value could not be stringified for encryption: ${err}`);
45+
}
46+
// For objects, we return an object with the encrypted JSON string under the 'secret' key.
47+
const encryptedJSONString = encryptInputSecretValue({ value: valueStr, publicKey });
48+
return { secret: encryptedJSONString } as ResultType;
3349
}
3450

3551
/**
@@ -50,8 +66,17 @@ export function encryptInputSecrets<T extends Record<string, any>>(
5066
const value = input[key];
5167
// NOTE: Skips already encrypted values. It can happens in case client already encrypted values, before
5268
// sending them using API. Or input was takes from task, run console or scheduler, where input is stored encrypted.
53-
if (value && ow.isValid(value, ow.string) && !ENCRYPTED_INPUT_VALUE_REGEXP.test(value)) {
54-
encryptedInput[key] = encryptInputSecretValue({ value: input[key], publicKey });
69+
const isUnencryptedString = ow.isValid(value, ow.string) && !ENCRYPTED_INPUT_VALUE_REGEXP.test(value);
70+
const isUnencryptedObject = ow.isValid(value, ow.object)
71+
&& (typeof (value as any).secret !== 'string' || !ENCRYPTED_INPUT_VALUE_REGEXP.test((value as any).secret));
72+
73+
if (isUnencryptedString || isUnencryptedObject) {
74+
try {
75+
encryptedInput[key] = encryptInputSecretValue({ value: input[key], publicKey });
76+
} catch (err) {
77+
throw new Error(`The input field "${key}" could not be encrypted. Try updating the field's value in the input editor. `
78+
+ `Encryption error: ${err}`);
79+
}
5580
}
5681
}
5782

@@ -72,7 +97,11 @@ export function decryptInputSecrets<T>(
7297

7398
const decryptedInput = {} as Record<string, any>;
7499
for (const [key, value] of Object.entries(input)) {
75-
if (ow.isValid(value, ow.string) && ENCRYPTED_INPUT_VALUE_REGEXP.test(value)) {
100+
const isEncryptedString = typeof value === 'string' && ENCRYPTED_INPUT_VALUE_REGEXP.test(value);
101+
const isEncryptedObject = typeof value === 'object' && typeof (value as any).secret === 'string'
102+
&& ENCRYPTED_INPUT_VALUE_REGEXP.test((value as any).secret);
103+
104+
if (isEncryptedString) {
76105
const match = value.match(ENCRYPTED_INPUT_VALUE_REGEXP);
77106
if (!match) continue;
78107
const [, encryptedPassword, encryptedValue] = match;
@@ -82,6 +111,15 @@ export function decryptInputSecrets<T>(
82111
throw new Error(`The input field "${key}" could not be decrypted. Try updating the field's value in the input editor. `
83112
+ `Decryption error: ${err}`);
84113
}
114+
} else if (isEncryptedObject) {
115+
// For objects, we are passing the encrypted object with `secret` key as an input to decryption.
116+
// So we extract the encrypted JSON string and can construct the decrypted object.
117+
const decryptedJSONString = decryptInputSecrets({ input: { [key]: (value as any).secret }, privateKey })[key];
118+
try {
119+
decryptedInput[key] = JSON.parse(decryptedJSONString);
120+
} catch (err) {
121+
throw new Error(`The input field "${key}" could not be parsed as JSON after decryption: ${err}`);
122+
}
85123
}
86124
}
87125

test/input_schema_definition.test.ts

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -236,13 +236,15 @@ describe('input_schema.json', () => {
236236
});
237237
});
238238

239-
it('should allow only string type', () => {
239+
it('should allow only string and object type', () => {
240240
[{ type: 'string', editor: 'textfield' }].forEach((fields) => {
241241
expect(isSchemaValid(fields, true)).toBe(true);
242242
});
243+
[{ type: 'object', editor: 'json' }].forEach((fields) => {
244+
expect(isSchemaValid(fields, true)).toBe(true);
245+
});
243246
[
244247
{ type: 'array', editor: 'stringList' },
245-
{ type: 'object', editor: 'json' },
246248
{ type: 'boolean' },
247249
{ type: 'integer' },
248250
].forEach((fields) => {
@@ -301,6 +303,84 @@ describe('input_schema.json', () => {
301303
});
302304
});
303305

306+
describe('special cases for isSecret object type', () => {
307+
const isSchemaValid = (fields: object, isSecret?: boolean) => {
308+
return ajv.validate(inputSchema, {
309+
title: 'Test input schema',
310+
type: 'object',
311+
schemaVersion: 1,
312+
properties: {
313+
myField: {
314+
title: 'Field title',
315+
description: 'My test field',
316+
type: 'object',
317+
isSecret,
318+
...fields,
319+
},
320+
},
321+
});
322+
};
323+
324+
it('should not allow all editors', () => {
325+
['json', 'hidden'].forEach((editor) => {
326+
expect(isSchemaValid({ editor }, true)).toBe(true);
327+
});
328+
['proxy'].forEach((editor) => {
329+
expect(isSchemaValid({ editor }, true)).toBe(false);
330+
});
331+
});
332+
333+
it('should not allow some fields', () => {
334+
['minProperties', 'maxProperties'].forEach((intField) => {
335+
expect(isSchemaValid({ [intField]: 10 }, true)).toBe(false);
336+
});
337+
['patternKey', 'patternValue', 'prefill', 'example'].forEach((stringField) => {
338+
expect(isSchemaValid({ [stringField]: 'bla' }, true)).toBe(false);
339+
});
340+
});
341+
342+
it('should work without isSecret with all editors and properties', () => {
343+
expect(ajv.validate(inputSchema, {
344+
title: 'Test input schema',
345+
type: 'object',
346+
schemaVersion: 1,
347+
properties: {
348+
myField: {
349+
title: 'Field title',
350+
description: 'My test field',
351+
type: 'object',
352+
editor: 'json',
353+
isSecret: false,
354+
minProperties: 2,
355+
maxProperties: 100,
356+
default: { key: 'value' },
357+
prefill: { key: 'value', key2: 'value2' },
358+
},
359+
},
360+
})).toBe(true);
361+
362+
expect(ajv.validate(inputSchema, {
363+
title: 'Test input schema',
364+
type: 'object',
365+
schemaVersion: 1,
366+
properties: {
367+
myField: {
368+
title: 'Field title',
369+
description: 'My test field',
370+
type: 'object',
371+
editor: 'json',
372+
isSecret: false,
373+
minProperties: 2,
374+
maxProperties: 100,
375+
default: { key: 'value' },
376+
prefill: { key: 'value', key2: 'value2' },
377+
bla: 'bla', // Validation failed because additional property
378+
},
379+
},
380+
})).toBe(false);
381+
});
382+
});
383+
304384
describe('special cases for datepicker editor type', () => {
305385
it('should accept dateType field omitted', () => {
306386
expect(ajv.validate(inputSchema, {

test/input_secrets.test.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ const inputSchema = {
2424
isSecret: true,
2525
description: 'Description',
2626
},
27+
secureObject: {
28+
title: 'Secure Object',
29+
type: 'object',
30+
editor: 'json',
31+
isSecret: true,
32+
description: 'Description',
33+
},
2734
customString: {
2835
title: 'String',
2936
type: 'string',
@@ -36,15 +43,30 @@ const inputSchema = {
3643

3744
describe('input secrets', () => {
3845
it('should decrypt encrypted values correctly', () => {
39-
const testInput = { secure: 'my secret string', customString: 'just string' };
46+
const testInput = {
47+
secure: 'my secret string',
48+
secureObject: {
49+
key1: 'value1',
50+
key2: 'value2',
51+
},
52+
customString: 'just string',
53+
};
4054
const encryptedInput = encryptInputSecrets({ input: testInput, inputSchema, publicKey });
4155
expect(encryptedInput.secure).not.toEqual(testInput.secure);
56+
expect(encryptedInput.secureObject).not.toEqual(testInput.secureObject);
4257
expect(encryptedInput.customString).toEqual(testInput.customString);
4358
expect(testInput).toStrictEqual(decryptInputSecrets({ input: encryptedInput, privateKey }));
4459
});
4560

4661
it('should not decrypt already decrypted values', () => {
47-
const testInput = { secure: 'my secret', customString: 'just string' };
62+
const testInput = {
63+
secure: 'my secret string',
64+
secureObject: {
65+
key1: 'value1',
66+
key2: 'value2',
67+
},
68+
customString: 'just string',
69+
};
4870
const encrypted1 = encryptInputSecrets({ input: testInput, inputSchema, publicKey });
4971
const encrypted2 = encryptInputSecrets({ input: encrypted1, inputSchema, publicKey });
5072
expect(testInput).toStrictEqual(decryptInputSecrets({ input: encrypted2, privateKey }));
@@ -58,4 +80,24 @@ describe('input secrets', () => {
5880
expect(() => decryptInputSecrets({ input: encryptedInput, privateKey: publicKey }))
5981
.toThrow(`The input field "secure" could not be decrypted. Try updating the field's value in the input editor.`);
6082
});
83+
84+
it('should throw if secret object is not valid json', () => {
85+
// eslint-disable-next-line max-len
86+
const secure = 'ENCRYPTED_VALUE:M8QcrS+opESY1KTi4bLvAx0Czxa+idIBq3XKD6gbzb7/CpK9soZrFhqgUIWsFKHMxbISUQu/Btex+WmakhDJFRA/vLLBp4Mit9JY+hwfnfQcBfwuI+ajqYyary6YqQth6gHKF5TZqhu2S1lc+O5t4oRRTCm+Qyk2dYY5nP0muCixatFT3Fu5UzpbFhElH8QiEbySy5jtjZLHZmFe9oPdk3Z8fV0nug9QlEuvYwR1eWK7e0A72zklgfBVNvjsA7OJ2rkaHHef6x6s36k4nI8uIvEHMOZJfuTBjail8xW00BrsKiecuTuRsREYinAMUszunqg0uJthhJFk+3GsrJEkIg==:LX2wyg1xhv94GQf7GRnR8ySbNrdlGrN0icw55a5H3kXhZ2SdOriLcjyPAU9GJob/NlFjzNkf';
87+
// This is an example of an encrypted object that is not valid JSON:
88+
// { "key1": "value1", "key2" }
89+
// This should never happen in practice, but we want to test that the decryption function handles it gracefully.
90+
const secureObject = {
91+
// eslint-disable-next-line max-len
92+
secret: 'ENCRYPTED_VALUE:kGUk2YdlMZGKdycmBUUZMSbZh/GMB+wvXkWDuI6G9cIzBnKQEqngpCb/lJSSdM4Gd1Xy6rwBVMxGm6ntnYaOyx6lgZqBs5hQqMe3Q0rK2ToW279ZNVNdMmeQDjPKKPpYEpz6p9yAmrRvWu7+1fW6UmazSYj1ErLI9WVJnG3MXb3CsSfQa3HHZ7Qtmgx5AXGT19z24cVSMqWsQOyJW2UwB83jcKcxqAS4w0YV9GsLgMX0K01BR1sXP303Om8c28h6EW6+Ad02pGWwANWjszwY/cWjCNXd44BqJxssLZ3rfk1EG8MkosdK0Zem9/8O4TCbxEAr7hQ2qVwNf43h4si05w==:ry21ohthwOdgBIR9TN0kxpSBe+h7rwhIxvSe4carBWYQWHSiYptLceQ55F8=',
93+
};
94+
95+
const encryptedInput = {
96+
secure,
97+
secureObject,
98+
customString: 'just string',
99+
};
100+
expect(() => decryptInputSecrets({ input: encryptedInput, privateKey }))
101+
.toThrow(`The input field "secureObject" could not be parsed as JSON after decryption`);
102+
});
61103
});

0 commit comments

Comments
 (0)