diff --git a/packages/input_schema/src/schema.json b/packages/input_schema/src/schema.json index 0475d7aa..25d2ea5b 100644 --- a/packages/input_schema/src/schema.json +++ b/packages/input_schema/src/schema.json @@ -237,25 +237,58 @@ "objectProperty": { "title": "Object property", "type": "object", - "additionalProperties": false, + "additionalProperties": true, "properties": { "type": { "enum": ["object"] }, "title": { "type": "string" }, "description": { "type": "string" }, - "default": { "type": "object" }, - "prefill": { "type": "object" }, - "example": { "type": "object" }, - "patternKey": { "type": "string" }, - "patternValue": { "type": "string" }, - "nullable": { "type": "boolean" }, - "minProperties": { "type": "integer" }, - "maxProperties": { "type": "integer" }, - "editor": { "enum": ["json", "proxy", "hidden"] }, - "sectionCaption": { "type": "string" }, - "sectionDescription": { "type": "string" } + "isSecret": { "type": "boolean" } }, - "required": ["type", "title", "description", "editor"] + "required": ["type", "title", "description", "editor"], + "if": { + "properties": { + "isSecret": { + "not": { + "const": true + } + } + } + }, + "then": { + "additionalProperties": false, + "properties": { + "type": { "enum": ["object"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "default": { "type": "object" }, + "prefill": { "type": "object" }, + "example": { "type": "object" }, + "patternKey": { "type": "string" }, + "patternValue": { "type": "string" }, + "nullable": { "type": "boolean" }, + "minProperties": { "type": "integer" }, + "maxProperties": { "type": "integer" }, + "editor": { "enum": ["json", "proxy", "hidden"] }, + "sectionCaption": { "type": "string" }, + "sectionDescription": { "type": "string" }, + "isSecret": { "enum": [false] } + } + }, + "else": { + "additionalProperties": false, + "properties": { + "type": { "enum": ["object"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "example": { "type": "object" }, + "nullable": { "type": "boolean" }, + "editor": { "enum": ["json", "hidden"] }, + "sectionCaption": { "type": "string" }, + "sectionDescription": { "type": "string" }, + "isSecret": { "enum": [true] } + } + } }, "integerProperty": { "title": "Integer property", diff --git a/packages/input_secrets/src/input_secrets.ts b/packages/input_secrets/src/input_secrets.ts index 9e8a1fb4..1dcacb84 100644 --- a/packages/input_secrets/src/input_secrets.ts +++ b/packages/input_secrets/src/input_secrets.ts @@ -23,13 +23,29 @@ export function getInputSchemaSecretFieldKeys(inputSchema: any): string[] { /** * Encrypts input secret value + * Depending on the type of value, it returns either a string (for strings) or an object (for objects) with the `secret` key. */ -export function encryptInputSecretValue({ value, publicKey }: { value: string, publicKey: KeyObject }): string { - ow(value, ow.string); +export function encryptInputSecretValue({ value, publicKey }: { value: T, publicKey: KeyObject }): + T extends string ? string : { secret: string } { + ow(value, ow.any(ow.string, ow.object)); ow(publicKey, ow.object.instanceOf(KeyObject)); - const { encryptedValue, encryptedPassword } = publicEncrypt({ value, publicKey }); - return `${ENCRYPTED_INPUT_VALUE_PREFIX}:${encryptedPassword}:${encryptedValue}`; + type ResultType = T extends string ? string : { secret: string }; + + if (typeof value === 'string') { + const { encryptedValue, encryptedPassword } = publicEncrypt({ value, publicKey }); + return `${ENCRYPTED_INPUT_VALUE_PREFIX}:${encryptedPassword}:${encryptedValue}` as ResultType; + } + + let valueStr: string; + try { + valueStr = JSON.stringify(value); + } catch (err) { + throw new Error(`The input value could not be stringified for encryption: ${err}`); + } + // For objects, we return an object with the encrypted JSON string under the 'secret' key. + const encryptedJSONString = encryptInputSecretValue({ value: valueStr, publicKey }); + return { secret: encryptedJSONString } as ResultType; } /** @@ -50,8 +66,17 @@ export function encryptInputSecrets>( const value = input[key]; // NOTE: Skips already encrypted values. It can happens in case client already encrypted values, before // sending them using API. Or input was takes from task, run console or scheduler, where input is stored encrypted. - if (value && ow.isValid(value, ow.string) && !ENCRYPTED_INPUT_VALUE_REGEXP.test(value)) { - encryptedInput[key] = encryptInputSecretValue({ value: input[key], publicKey }); + const isUnencryptedString = ow.isValid(value, ow.string) && !ENCRYPTED_INPUT_VALUE_REGEXP.test(value); + const isUnencryptedObject = ow.isValid(value, ow.object) + && (typeof (value as any).secret !== 'string' || !ENCRYPTED_INPUT_VALUE_REGEXP.test((value as any).secret)); + + if (isUnencryptedString || isUnencryptedObject) { + try { + encryptedInput[key] = encryptInputSecretValue({ value: input[key], publicKey }); + } catch (err) { + throw new Error(`The input field "${key}" could not be encrypted. Try updating the field's value in the input editor. ` + + `Encryption error: ${err}`); + } } } @@ -72,7 +97,11 @@ export function decryptInputSecrets( const decryptedInput = {} as Record; for (const [key, value] of Object.entries(input)) { - if (ow.isValid(value, ow.string) && ENCRYPTED_INPUT_VALUE_REGEXP.test(value)) { + const isEncryptedString = typeof value === 'string' && ENCRYPTED_INPUT_VALUE_REGEXP.test(value); + const isEncryptedObject = typeof value === 'object' && typeof (value as any).secret === 'string' + && ENCRYPTED_INPUT_VALUE_REGEXP.test((value as any).secret); + + if (isEncryptedString) { const match = value.match(ENCRYPTED_INPUT_VALUE_REGEXP); if (!match) continue; const [, encryptedPassword, encryptedValue] = match; @@ -82,6 +111,15 @@ export function decryptInputSecrets( throw new Error(`The input field "${key}" could not be decrypted. Try updating the field's value in the input editor. ` + `Decryption error: ${err}`); } + } else if (isEncryptedObject) { + // For objects, we are passing the encrypted object with `secret` key as an input to decryption. + // So we extract the encrypted JSON string and can construct the decrypted object. + const decryptedJSONString = decryptInputSecrets({ input: { [key]: (value as any).secret }, privateKey })[key]; + try { + decryptedInput[key] = JSON.parse(decryptedJSONString); + } catch (err) { + throw new Error(`The input field "${key}" could not be parsed as JSON after decryption: ${err}`); + } } } diff --git a/test/input_schema_definition.test.ts b/test/input_schema_definition.test.ts index ee5efab9..3df0d72c 100644 --- a/test/input_schema_definition.test.ts +++ b/test/input_schema_definition.test.ts @@ -236,13 +236,15 @@ describe('input_schema.json', () => { }); }); - it('should allow only string type', () => { + it('should allow only string and object type', () => { [{ type: 'string', editor: 'textfield' }].forEach((fields) => { expect(isSchemaValid(fields, true)).toBe(true); }); + [{ type: 'object', editor: 'json' }].forEach((fields) => { + expect(isSchemaValid(fields, true)).toBe(true); + }); [ { type: 'array', editor: 'stringList' }, - { type: 'object', editor: 'json' }, { type: 'boolean' }, { type: 'integer' }, ].forEach((fields) => { @@ -301,6 +303,84 @@ describe('input_schema.json', () => { }); }); + describe('special cases for isSecret object type', () => { + const isSchemaValid = (fields: object, isSecret?: boolean) => { + return ajv.validate(inputSchema, { + title: 'Test input schema', + type: 'object', + schemaVersion: 1, + properties: { + myField: { + title: 'Field title', + description: 'My test field', + type: 'object', + isSecret, + ...fields, + }, + }, + }); + }; + + it('should not allow all editors', () => { + ['json', 'hidden'].forEach((editor) => { + expect(isSchemaValid({ editor }, true)).toBe(true); + }); + ['proxy'].forEach((editor) => { + expect(isSchemaValid({ editor }, true)).toBe(false); + }); + }); + + it('should not allow some fields', () => { + ['minProperties', 'maxProperties'].forEach((intField) => { + expect(isSchemaValid({ [intField]: 10 }, true)).toBe(false); + }); + ['patternKey', 'patternValue', 'prefill', 'example'].forEach((stringField) => { + expect(isSchemaValid({ [stringField]: 'bla' }, true)).toBe(false); + }); + }); + + it('should work without isSecret with all editors and properties', () => { + expect(ajv.validate(inputSchema, { + title: 'Test input schema', + type: 'object', + schemaVersion: 1, + properties: { + myField: { + title: 'Field title', + description: 'My test field', + type: 'object', + editor: 'json', + isSecret: false, + minProperties: 2, + maxProperties: 100, + default: { key: 'value' }, + prefill: { key: 'value', key2: 'value2' }, + }, + }, + })).toBe(true); + + expect(ajv.validate(inputSchema, { + title: 'Test input schema', + type: 'object', + schemaVersion: 1, + properties: { + myField: { + title: 'Field title', + description: 'My test field', + type: 'object', + editor: 'json', + isSecret: false, + minProperties: 2, + maxProperties: 100, + default: { key: 'value' }, + prefill: { key: 'value', key2: 'value2' }, + bla: 'bla', // Validation failed because additional property + }, + }, + })).toBe(false); + }); + }); + describe('special cases for datepicker editor type', () => { it('should accept dateType field omitted', () => { expect(ajv.validate(inputSchema, { diff --git a/test/input_secrets.test.ts b/test/input_secrets.test.ts index 6748a3cb..3b8bcbbe 100644 --- a/test/input_secrets.test.ts +++ b/test/input_secrets.test.ts @@ -24,6 +24,13 @@ const inputSchema = { isSecret: true, description: 'Description', }, + secureObject: { + title: 'Secure Object', + type: 'object', + editor: 'json', + isSecret: true, + description: 'Description', + }, customString: { title: 'String', type: 'string', @@ -36,15 +43,30 @@ const inputSchema = { describe('input secrets', () => { it('should decrypt encrypted values correctly', () => { - const testInput = { secure: 'my secret string', customString: 'just string' }; + const testInput = { + secure: 'my secret string', + secureObject: { + key1: 'value1', + key2: 'value2', + }, + customString: 'just string', + }; const encryptedInput = encryptInputSecrets({ input: testInput, inputSchema, publicKey }); expect(encryptedInput.secure).not.toEqual(testInput.secure); + expect(encryptedInput.secureObject).not.toEqual(testInput.secureObject); expect(encryptedInput.customString).toEqual(testInput.customString); expect(testInput).toStrictEqual(decryptInputSecrets({ input: encryptedInput, privateKey })); }); it('should not decrypt already decrypted values', () => { - const testInput = { secure: 'my secret', customString: 'just string' }; + const testInput = { + secure: 'my secret string', + secureObject: { + key1: 'value1', + key2: 'value2', + }, + customString: 'just string', + }; const encrypted1 = encryptInputSecrets({ input: testInput, inputSchema, publicKey }); const encrypted2 = encryptInputSecrets({ input: encrypted1, inputSchema, publicKey }); expect(testInput).toStrictEqual(decryptInputSecrets({ input: encrypted2, privateKey })); @@ -58,4 +80,24 @@ describe('input secrets', () => { expect(() => decryptInputSecrets({ input: encryptedInput, privateKey: publicKey })) .toThrow(`The input field "secure" could not be decrypted. Try updating the field's value in the input editor.`); }); + + it('should throw if secret object is not valid json', () => { + // eslint-disable-next-line max-len + const secure = 'ENCRYPTED_VALUE:M8QcrS+opESY1KTi4bLvAx0Czxa+idIBq3XKD6gbzb7/CpK9soZrFhqgUIWsFKHMxbISUQu/Btex+WmakhDJFRA/vLLBp4Mit9JY+hwfnfQcBfwuI+ajqYyary6YqQth6gHKF5TZqhu2S1lc+O5t4oRRTCm+Qyk2dYY5nP0muCixatFT3Fu5UzpbFhElH8QiEbySy5jtjZLHZmFe9oPdk3Z8fV0nug9QlEuvYwR1eWK7e0A72zklgfBVNvjsA7OJ2rkaHHef6x6s36k4nI8uIvEHMOZJfuTBjail8xW00BrsKiecuTuRsREYinAMUszunqg0uJthhJFk+3GsrJEkIg==:LX2wyg1xhv94GQf7GRnR8ySbNrdlGrN0icw55a5H3kXhZ2SdOriLcjyPAU9GJob/NlFjzNkf'; + // This is an example of an encrypted object that is not valid JSON: + // { "key1": "value1", "key2" } + // This should never happen in practice, but we want to test that the decryption function handles it gracefully. + const secureObject = { + // eslint-disable-next-line max-len + secret: 'ENCRYPTED_VALUE:kGUk2YdlMZGKdycmBUUZMSbZh/GMB+wvXkWDuI6G9cIzBnKQEqngpCb/lJSSdM4Gd1Xy6rwBVMxGm6ntnYaOyx6lgZqBs5hQqMe3Q0rK2ToW279ZNVNdMmeQDjPKKPpYEpz6p9yAmrRvWu7+1fW6UmazSYj1ErLI9WVJnG3MXb3CsSfQa3HHZ7Qtmgx5AXGT19z24cVSMqWsQOyJW2UwB83jcKcxqAS4w0YV9GsLgMX0K01BR1sXP303Om8c28h6EW6+Ad02pGWwANWjszwY/cWjCNXd44BqJxssLZ3rfk1EG8MkosdK0Zem9/8O4TCbxEAr7hQ2qVwNf43h4si05w==:ry21ohthwOdgBIR9TN0kxpSBe+h7rwhIxvSe4carBWYQWHSiYptLceQ55F8=', + }; + + const encryptedInput = { + secure, + secureObject, + customString: 'just string', + }; + expect(() => decryptInputSecrets({ input: encryptedInput, privateKey })) + .toThrow(`The input field "secureObject" could not be parsed as JSON after decryption`); + }); });