diff --git a/npmDepsHash b/npmDepsHash index c9e52b67..c4b2f81a 100644 --- a/npmDepsHash +++ b/npmDepsHash @@ -1 +1 @@ -sha256-/SFcicpWX1SFllzmz0acUYxtIV0LuBw1PU32Uk5hLzw= +sha256-q99IFZwwPBR/p2m18c5DOApks12rB779c8rUW160k3g= diff --git a/package-lock.json b/package-lock.json index 76e720dc..09cf6a7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "polykey": "dist/polykey.mjs" }, "devDependencies": { + "@apidevtools/json-schema-ref-parser": "^11.9.3", "@fast-check/jest": "^2.1.1", "@matrixai/errors": "^2.1.3", "@matrixai/exec": "^1.0.3", @@ -26,6 +27,8 @@ "@typescript-eslint/eslint-plugin": "^5.61.0", "@typescript-eslint/parser": "^5.61.0", "@yao-pkg/pkg": "^6.4.0", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", "commander": "^8.3.0", "cross-env": "^7.0.3", "esbuild": "0.19.4", @@ -39,7 +42,7 @@ "nexpect": "^0.6.0", "node-gyp-build": "^4.8.4", "open": "^10.1.2", - "polykey": "^2.5.0", + "polykey": "^2.6.0", "shelljs": "^0.8.5", "shx": "^0.3.4", "tsx": "^3.12.7", @@ -93,6 +96,23 @@ "node": ">=6.0.0" } }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "11.9.3", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz", + "integrity": "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==", + "dev": true, + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, "node_modules/@babel/code-frame": { "version": "7.22.13", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", @@ -1206,6 +1226,30 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "peer": true + }, "node_modules/@eslint/js": { "version": "8.51.0", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.51.0.tgz", @@ -1939,6 +1983,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "dev": true + }, "node_modules/@matrixai/async-cancellable": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@matrixai/async-cancellable/-/async-cancellable-2.0.1.tgz", @@ -2374,6 +2424,22 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@matrixai/lint/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/@matrixai/lint/node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", @@ -2555,6 +2621,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@matrixai/lint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, "node_modules/@matrixai/logger": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/@matrixai/logger/-/logger-4.0.3.tgz", @@ -3621,21 +3693,38 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -6169,6 +6258,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/eslint/node_modules/eslint-scope": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", @@ -6196,6 +6302,13 @@ "node": ">=4.0" } }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "peer": true + }, "node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", @@ -6443,6 +6556,22 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fast-uri": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", + "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, "node_modules/fastq": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", @@ -8493,9 +8622,9 @@ "dev": true }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true }, "node_modules/json-stable-stringify-without-jsonify": { @@ -9683,9 +9812,9 @@ } }, "node_modules/polykey": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/polykey/-/polykey-2.5.0.tgz", - "integrity": "sha512-cAtC5mdJQTnrcgPR0PEBzCCNvqoQ64/9CdQ56eEBKZeDXJmOUj66LjpyZkTuNqUgR9kN4n1dN98k3zAMOscATA==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/polykey/-/polykey-2.6.0.tgz", + "integrity": "sha512-213GWkrP0oBLvSEB7qYDMa+iqxy7QuZ1XaX6k9oSF9np9mvrHdWlopv45FtUkSm1vrDNtl9HOxWkH+Ij6EujAg==", "dev": true, "dependencies": { "@matrixai/async-cancellable": "^2.0.1", @@ -9760,12 +9889,6 @@ "integrity": "sha512-0CNTVCLZggSh7bc5VkX5WWPWO+cyZbNd07IHIsSXLia/eAq+r836hgk+8BKoEh7949Mda87VUOitx5OddVj64A==", "dev": true }, - "node_modules/polykey/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, "node_modules/polykey/node_modules/minimatch": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", diff --git a/package.json b/package.json index f2249501..bd5a85fb 100644 --- a/package.json +++ b/package.json @@ -136,6 +136,7 @@ "sodium-native": "*" }, "devDependencies": { + "@apidevtools/json-schema-ref-parser": "^11.9.3", "@fast-check/jest": "^2.1.1", "@matrixai/errors": "^2.1.3", "@matrixai/exec": "^1.0.3", @@ -149,6 +150,8 @@ "@typescript-eslint/eslint-plugin": "^5.61.0", "@typescript-eslint/parser": "^5.61.0", "@yao-pkg/pkg": "^6.4.0", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", "commander": "^8.3.0", "cross-env": "^7.0.3", "esbuild": "0.19.4", @@ -162,7 +165,7 @@ "nexpect": "^0.6.0", "node-gyp-build": "^4.8.4", "open": "^10.1.2", - "polykey": "^2.5.0", + "polykey": "^2.6.0", "shelljs": "^0.8.5", "shx": "^0.3.4", "tsx": "^3.12.7", diff --git a/src/errors.ts b/src/errors.ts index e44809dd..2e62c3b6 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -166,6 +166,11 @@ class ErrorPolykeyCLIDuplicateEnvName extends ErrorPolykeyCLI { exitCode = sysexits.USAGE; } +class ErrorPolykeyCLIMissingRequiredEnvName extends ErrorPolykeyCLI { + static description = 'A required environment variable is not present'; + exitCode = sysexits.USAGE; +} + class ErrorPolykeyCLIMakeDirectory extends ErrorPolykeyCLI { static description = 'Failed to create one or more directories'; exitCode = 1; @@ -197,10 +202,15 @@ class ErrorPolykeyCLITouchSecret extends ErrorPolykeyCLI { } class ErrorPolykeyCLIInvalidJWT extends ErrorPolykeyCLI { - static description: 'JWT is not valid'; + static description = 'JWT is not valid'; exitCode = sysexits.USAGE; } +class ErrorPolykeyCLISchemaInvalid extends ErrorPolykeyCLI { + static description = 'The provided JSON schema is invalid'; + exitCode = sysexits.CONFIG; +} + export { ErrorPolykeyCLI, ErrorPolykeyCLIUncaughtException, @@ -223,6 +233,7 @@ export { ErrorPolykeyCLINodePingFailed, ErrorPolykeyCLIInvalidEnvName, ErrorPolykeyCLIDuplicateEnvName, + ErrorPolykeyCLIMissingRequiredEnvName, ErrorPolykeyCLIMakeDirectory, ErrorPolykeyCLIRenameSecret, ErrorPolykeyCLIRemoveSecret, @@ -230,4 +241,5 @@ export { ErrorPolykeyCLIEditSecret, ErrorPolykeyCLITouchSecret, ErrorPolykeyCLIInvalidJWT, + ErrorPolykeyCLISchemaInvalid, }; diff --git a/src/secrets/CommandEnv.ts b/src/secrets/CommandEnv.ts index 0bc041b2..43d5a98d 100644 --- a/src/secrets/CommandEnv.ts +++ b/src/secrets/CommandEnv.ts @@ -2,8 +2,9 @@ import type PolykeyClient from 'polykey/PolykeyClient.js'; import type { ParsedSecretPathValue } from '../types.js'; import path from 'node:path'; import os from 'node:os'; +import $RefParser from '@apidevtools/json-schema-ref-parser'; +import { Ajv2019 } from 'ajv/dist/2019.js'; import { InvalidArgumentError } from 'commander'; -import * as utils from 'polykey/utils/index.js'; import CommandPolykey from '../CommandPolykey.js'; import * as binProcessors from '../utils/processors.js'; import * as binUtils from '../utils/index.js'; @@ -26,6 +27,7 @@ class CommandEnv extends CommandPolykey { this.addOption(binOptions.envDuplicate); this.addOption(binOptions.envExport); this.addOption(binOptions.preserveNewline); + this.addOption(binOptions.egressSchema); this.argument( '', 'command and arguments formatted as [-- cmd [cmdArgs...]]', @@ -34,6 +36,7 @@ class CommandEnv extends CommandPolykey { const { default: PolykeyClient } = await import( 'polykey/PolykeyClient.js' ); + const utils = await import('polykey/utils/index.js'); const { envInvalid, envDuplicate, @@ -53,6 +56,7 @@ class CommandEnv extends CommandPolykey { if (secretPath == null) preservedSecrets.add(vaultName); else preservedSecrets.add(`${vaultName}:${secretPath}`); } + // There are a few stages here // 1. parse the desired secrets // 2. obtain the desired secrets @@ -122,23 +126,24 @@ class CommandEnv extends CommandPolykey { const [envp] = await binUtils.retryAuthentication(async (auth) => { const responseStream = await pkClient.rpcClient.methods.vaultsSecretsEnv(); - // Writing desired secrets + + // Writing desired secrets. Attempt to get all the required secrets. + // If the schema is provided, then the resulting variables will be + // validated. const secretRenameMap = new Map(); - const writeP = (async () => { - const writer = responseStream.writable.getWriter(); - let first = true; - for (const envVariable of envVariables) { - const [nameOrId, secretName, secretNameNew] = envVariable; - secretRenameMap.set(secretName ?? '/', secretNameNew); - await writer.write({ - nameOrId: nameOrId, - secretName: secretName ?? '/', - metadata: first ? auth : undefined, - }); - first = false; - } - await writer.close(); - })(); + const writer = responseStream.writable.getWriter(); + let first = true; + for (const envVariable of envVariables) { + const [nameOrId, secretName, secretNameNew] = envVariable; + secretRenameMap.set(secretName ?? '/', secretNameNew); + await writer.write({ + nameOrId: nameOrId, + secretName: secretName ?? '/', + metadata: first ? auth : undefined, + }); + first = false; + } + await writer.close(); const envp: Record = {}; const envpPath: Record< @@ -149,6 +154,28 @@ class CommandEnv extends CommandPolykey { } > = {}; for await (const value of responseStream.readable) { + if (value.type === 'ErrorMessage') { + switch (value.code) { + case 'EINVAL': + // It is expected for the data to be populated with the + // offending vault name if the vault was not found. + throw new Error( + `TMP Vault "${value.data?.nameOrId}" does not exist`, + ); + case 'ENOENT': + // It is expected for the data to be populated with the + // offending secret and vault name if a secret was not found. + throw new Error( + `TMP Secret "${value.data?.secretName}" does not exist in vault "${value.data?.nameOrId}"`, + ); + default: + utils.never( + `Expected code to be one of EINVAL, ENOENT, received ${value.code}`, + ); + } + continue; + } + const { nameOrId, secretName, secretContent } = value; let newName = secretRenameMap.get(secretName); if (newName == null) { @@ -225,7 +252,34 @@ class CommandEnv extends CommandPolykey { secretName, }; } - await writeP; + + // Validate the schema + if (options.egressSchema != null) { + // Compose the schema as ajv cannot parse cross-schema refs + const schema = await $RefParser.bundle(options.egressSchema); + + // Validate the schema using ajv. This will also apply defaults and + // coerce types as necessary. + const ajv = new Ajv2019({ + strict: true, + allErrors: true, + useDefaults: true, + coerceTypes: true, + }); + const validate = ajv.compile(schema); + const valid = validate(envp); + if (!valid && validate.errors != null) { + throw new binErrors.ErrorPolykeyCLISchemaInvalid( + 'JSON schema validation failed', + { + data: { + errors: [...validate.errors], + }, + }, + ); + } + } + return [envp, envpPath]; }, meta); // End connection early to avoid errors on server diff --git a/src/types.ts b/src/types.ts index acf2bdea..40d1e68c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -63,6 +63,32 @@ type PromiseDeconstructed = { type ParsedSecretPathValue = [string, string?, string?]; +type JSONSchemaProps = Record< + string, + { + type?: string; + default?: unknown; + [key: string]: unknown; + } +>; + +type JSONSchema = { + type?: string; + properties?: Record; + required?: Array; + allOf?: Array; + anyOf?: Array; + oneOf?: Array; + [key: string]: unknown; +}; + +// Only strings are supported for the time being +type JSONSchemaInfo = { + allKeys: Array; + requiredKeys: Array; + defaults: Record; +}; + export type { TableRow, TableOptions, @@ -72,4 +98,7 @@ export type { AgentChildProcessOutput, PromiseDeconstructed, ParsedSecretPathValue, + JSONSchemaProps, + JSONSchema, + JSONSchemaInfo, }; diff --git a/src/utils/options.ts b/src/utils/options.ts index 1146c154..0347606f 100644 --- a/src/utils/options.ts +++ b/src/utils/options.ts @@ -325,6 +325,11 @@ const returnURLPath = new Option( 'Which path on the website to send the token to', ); +const egressSchema = new Option( + '--egress-schema ', + 'A JSON schema controlling the egressing secrets', +); + export { nodePath, format, @@ -371,4 +376,5 @@ export { parents, preserveNewline, returnURLPath, + egressSchema, }; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 882a2f93..09b13deb 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -5,6 +5,8 @@ import type { TableOptions, DictOptions, PromiseDeconstructed, + JSONSchema, + JSONSchemaInfo, } from '../types.js'; import process from 'node:process'; import { LogLevel } from '@matrixai/logger'; @@ -455,7 +457,9 @@ function outputFormatterError(err: any): string { output += '\n'; } } - output += `${indent}cause: `; + if (err.cause) { + output += `${indent}cause: `; + } err = err.cause; } else if (err instanceof ErrorPolykey) { output += `${err.name}: ${err.description}`; @@ -646,6 +650,47 @@ function jsonToCompactJWT(token: SignedTokenEncoded): string { return `${token.signatures[0].protected}.${token.payload}.${token.signatures[0].signature}`; } +function loadSchema(bundledSchema: JSONSchema): JSONSchemaInfo { + const props: Set = new Set(); + const required: Set = new Set(); + const defaults: Record = {}; + + const unwrapSchema = (schema: JSONSchema) => { + // Collect properties and their defaults + if (schema.properties != null) { + for (const [k, p] of Object.entries(schema.properties)) { + props.add(k); + if (p.default == null) continue; + if (typeof p.default !== 'string') { + throw new Error('TMP wrong type'); + } + defaults[k] = p.default; + } + } + + // Collect required properties + if (schema.required != null) { + schema.required.forEach((requiredSecret) => required.add(requiredSecret)); + } + + // Process composition keywords + const compositionKeywords = ['allOf', 'anyOf', 'oneOf'] as const; + compositionKeywords.forEach( + (keyword) => + schema[keyword] != null && + Array.isArray(schema[keyword]) && + schema[keyword]!.forEach(unwrapSchema), + ); + }; + unwrapSchema(bundledSchema); + + return { + allKeys: [...props], + requiredKeys: [...required], + defaults: defaults, + }; +} + export { verboseToLogLevel, standardErrorReplacer, @@ -670,6 +715,7 @@ export { promise, importFS, jsonToCompactJWT, + loadSchema, }; export type { OutputObject }; diff --git a/tests/secrets/env.test.ts b/tests/secrets/env.test.ts index 21cbd0d0..ff2c0d38 100644 --- a/tests/secrets/env.test.ts +++ b/tests/secrets/env.test.ts @@ -953,4 +953,485 @@ describe('commandEnv', () => { expect(result.stdout).toEqual(formatResult[format]); }, ); + + describe('should apply json schema to control secrets egress', () => { + test('should validate secrets based on a schema', async () => { + // Write secrets to vault + const vaultName = 'vault'; + const vaultId = await polykeyAgent.vaultManager.createVault(vaultName); + const secretName1 = 'SECRET1'; + const secretName2 = 'SECRET2'; + const secretName3 = 'SECRET3'; + await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => { + await vaultOps.addSecret(vault, secretName1, secretName1); + await vaultOps.addSecret(vault, secretName2, secretName2); + await vaultOps.addSecret(vault, secretName3, secretName3); + }); + + // Write schema to file + const schema = { + type: 'object', + properties: { + SECRET1: { type: 'string' }, + SECRET2: { type: 'string' }, + }, + required: ['SECRET1', 'SECRET2'], + }; + const schemaPath = path.join(dataDir, 'egress.schema.json'); + await fs.promises.writeFile(schemaPath, JSON.stringify(schema, null, 2)); + + // Run command with the schema + const command = [ + 'secrets', + 'env', + '-np', + dataDir, + '--env-format', + 'unix', + '--egress-schema', + schemaPath, + vaultName, + ]; + const result = await testUtils.pkExec(command, { + env: { PK_PASSWORD: password }, + }); + expect(result.exitCode).toBe(0); + + // Confirm all the secrets were exported + expect(result.stdout).toContain("SECRET1='SECRET1'"); + expect(result.stdout).toContain("SECRET2='SECRET2'"); + expect(result.stdout).toContain("SECRET3='SECRET3'"); + }); + + test('should fail if no additional properties are expected', async () => { + // Write secrets to vault + const vaultName = 'vault'; + const vaultId = await polykeyAgent.vaultManager.createVault(vaultName); + const secretName1 = 'SECRET1'; + const secretName2 = 'SECRET2'; + const secretName3 = 'SECRET3'; + await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => { + await vaultOps.addSecret(vault, secretName1, secretName1); + await vaultOps.addSecret(vault, secretName2, secretName2); + await vaultOps.addSecret(vault, secretName3, secretName3); + }); + + // Write schema to file + const schema = { + type: 'object', + properties: { + SECRET1: { type: 'string' }, + SECRET2: { type: 'string' }, + }, + required: ['SECRET1', 'SECRET2'], + additionalProperties: false, + }; + const schemaPath = path.join(dataDir, 'egress.schema.json'); + await fs.promises.writeFile(schemaPath, JSON.stringify(schema, null, 2)); + + // Run command with the schema + const command = [ + 'secrets', + 'env', + '-np', + dataDir, + '--env-format', + 'unix', + '--egress-schema', + schemaPath, + vaultName, + ]; + const result = await testUtils.pkExec(command, { + env: { PK_PASSWORD: password }, + }); + expect(result.exitCode).toBe(78); + + // Check if the errors are valid and correct + expect(result.stderr).toContain('ErrorPolykeyCLISchemaInvalid'); + expect(result.stderr).toContain('additionalProperties'); + }); + + test('should apply schema to secrets from multiple vaults', async () => { + // Write secrets to vault + const vaultName1 = 'vault1'; + const vaultName2 = 'vault2'; + const vaultId1 = await polykeyAgent.vaultManager.createVault(vaultName1); + const vaultId2 = await polykeyAgent.vaultManager.createVault(vaultName2); + const secretName1 = 'SECRET1'; + const secretName2 = 'SECRET2'; + const secretName3 = 'SECRET3'; + await polykeyAgent.vaultManager.withVaults( + [vaultId1, vaultId2], + async (vault1, vault2) => { + await vaultOps.addSecret(vault1, secretName1, secretName1); + await vaultOps.addSecret(vault2, secretName2, secretName2); + await vaultOps.addSecret(vault1, secretName3, secretName3); + }, + ); + + // Write schema to file + const schema = { + type: 'object', + properties: { + SECRET1: { type: 'string' }, + SECRET2: { type: 'string' }, + SECRET3: { type: 'string' }, + }, + required: ['SECRET1', 'SECRET2', 'SECRET3'], + }; + const schemaPath = path.join(dataDir, 'egress.schema.json'); + await fs.promises.writeFile(schemaPath, JSON.stringify(schema, null, 2)); + + // Run command with the schema + const command = [ + 'secrets', + 'env', + '-np', + dataDir, + '--env-format', + 'unix', + '--egress-schema', + schemaPath, + vaultName1, + vaultName2, + ]; + const result = await testUtils.pkExec(command, { + env: { PK_PASSWORD: password }, + }); + expect(result.exitCode).toBe(0); + + // Confirm only the specified secrets were exported + expect(result.stdout).toContain("SECRET1='SECRET1'"); + expect(result.stdout).toContain("SECRET2='SECRET2'"); + expect(result.stdout).toContain("SECRET3='SECRET3'"); + }); + + test('should handle secret renames', async () => { + // Write secrets to vault + const vaultName = 'vault'; + const vaultId = await polykeyAgent.vaultManager.createVault(vaultName); + const secretName1 = 'SECRET1'; + const secretName2 = 'SECRET2'; + const secretRename = 'RENAMED'; + await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => { + await vaultOps.addSecret(vault, secretName1, secretName1); + await vaultOps.addSecret(vault, secretName2, secretName2); + }); + + // Write schema to file + const schema = { + type: 'object', + properties: { + SECRET1: { type: 'string' }, + RENAMED: { type: 'string' }, + }, + required: ['SECRET1', 'RENAMED'], + }; + const schemaPath = path.join(dataDir, 'egress.schema.json'); + await fs.promises.writeFile(schemaPath, JSON.stringify(schema, null, 2)); + + // Run command with the schema. Should first export all relevant secrets + // from the vault, then process the renamed secret. + const command = [ + 'secrets', + 'env', + '-np', + dataDir, + '--env-format', + 'unix', + '--egress-schema', + schemaPath, + `${vaultName}:${secretName1}`, + `${vaultName}:${secretName2}=${secretRename}`, + ]; + const result = await testUtils.pkExec(command, { + env: { PK_PASSWORD: password }, + }); + expect(result.exitCode).toBe(0); + + // Confirm only the specified secrets were exported + expect(result.stdout).toContain("SECRET1='SECRET1'"); + expect(result.stdout).toContain("RENAMED='SECRET2'"); + expect(result.stdout).not.toContain("SECRET2='SECRET2'"); + }); + + test('should not fail when missing non-required secret', async () => { + // Write secrets to vault + const vaultName = 'vault'; + const vaultId = await polykeyAgent.vaultManager.createVault(vaultName); + const secretName1 = 'SECRET1'; + await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => { + await vaultOps.addSecret(vault, secretName1, secretName1); + }); + + // Write schema to file + const schema = { + type: 'object', + properties: { + SECRET1: { type: 'string' }, + SECRET2: { type: 'string' }, + }, + required: ['SECRET1'], + }; + const schemaPath = path.join(dataDir, 'egress.schema.json'); + await fs.promises.writeFile(schemaPath, JSON.stringify(schema, null, 2)); + + // Run command with the schema. Should first export all relevant secrets + // from the vault, then process the renamed secret. + const command = [ + 'secrets', + 'env', + '-np', + dataDir, + '--env-format', + 'unix', + '--egress-schema', + schemaPath, + vaultName, + ]; + const result = await testUtils.pkExec(command, { + env: { PK_PASSWORD: password }, + }); + expect(result.exitCode).toBe(0); + + // Confirm only the specified secrets were exported + expect(result.stdout).toContain("SECRET1='SECRET1'"); + expect(result.stdout).not.toContain('SECRET2'); + }); + + test('should fail when missing required secret', async () => { + // Write secrets to vault + const vaultName = 'vault'; + const vaultId = await polykeyAgent.vaultManager.createVault(vaultName); + const secretName1 = 'SECRET1'; + await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => { + await vaultOps.addSecret(vault, secretName1, secretName1); + }); + + // Write schema to file + const schema = { + type: 'object', + properties: { + SECRET1: { type: 'string' }, + SECRET2: { type: 'string' }, + }, + required: ['SECRET1', 'SECRET2'], + }; + const schemaPath = path.join(dataDir, 'egress.schema.json'); + await fs.promises.writeFile(schemaPath, JSON.stringify(schema, null, 2)); + + // Run command with the schema + const command = [ + 'secrets', + 'env', + '-np', + dataDir, + '--env-format', + 'unix', + '--egress-schema', + schemaPath, + vaultName, + ]; + const result = await testUtils.pkExec(command, { + env: { PK_PASSWORD: password }, + }); + expect(result.exitCode).toBe(78); + + // Confirm the validity of the error + expect(result.stderr).toInclude('ErrorPolykeyCLISchemaInvalid'); + expect(result.stderr).toInclude('SECRET2'); + }); + + test('should replace variable with default if present', async () => { + // Write secrets to vault + const vaultName = 'vault'; + const vaultId = await polykeyAgent.vaultManager.createVault(vaultName); + const secretName1 = 'SECRET1'; + await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => { + await vaultOps.addSecret(vault, secretName1, secretName1); + }); + + // Write schema to file + const schema = { + type: 'object', + properties: { + SECRET1: { type: 'string' }, + SECRET2: { type: 'string', default: 'abc' }, + }, + required: ['SECRET1', 'SECRET2'], + }; + const schemaPath = path.join(dataDir, 'egress.schema.json'); + await fs.promises.writeFile(schemaPath, JSON.stringify(schema, null, 2)); + + // Run command with the schema + const command = [ + 'secrets', + 'env', + '-np', + dataDir, + '--env-format', + 'unix', + '--egress-schema', + schemaPath, + vaultName, + ]; + const result = await testUtils.pkExec(command, { + env: { PK_PASSWORD: password }, + }); + expect(result.exitCode).toBe(0); + + // Confirm only the specified secrets were exported + expect(result.stdout).toInclude("SECRET1='SECRET1'"); + expect(result.stdout).toInclude("SECRET2='abc"); + }); + + test('should validate variable types', async () => { + // Write secrets to vault + const vaultName = 'vault'; + const vaultId = await polykeyAgent.vaultManager.createVault(vaultName); + const secretName1 = 'SECRET1'; + const secretData1 = 'secret string'; + const secretName2 = 'SECRET2'; + const secretData2 = '123'; + await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => { + await vaultOps.addSecret(vault, secretName1, secretData1); + await vaultOps.addSecret(vault, secretName2, secretData2); + }); + + // Write schema to file. Schema 1 should be valid, and schema 2 should + // fail validation of the secrets. + const schema1 = { + type: 'object', + properties: { + SECRET1: { type: 'string' }, + SECRET2: { type: 'number' }, + }, + required: ['SECRET1', 'SECRET2'], + }; + const schema2 = { + type: 'object', + properties: { + SECRET1: { type: 'number' }, + SECRET2: { type: 'string' }, + }, + required: ['SECRET1', 'SECRET2'], + }; + const schema1Path = path.join(dataDir, 'egress1.schema.json'); + const schema2Path = path.join(dataDir, 'egress2.schema.json'); + await fs.promises.writeFile( + schema1Path, + JSON.stringify(schema1, null, 2), + ); + await fs.promises.writeFile( + schema2Path, + JSON.stringify(schema2, null, 2), + ); + + // Run command with the valid schema + const command1 = [ + 'secrets', + 'env', + '-np', + dataDir, + '--env-format', + 'unix', + '--egress-schema', + schema1Path, + vaultName, + ]; + const result1 = await testUtils.pkExec(command1, { + env: { PK_PASSWORD: password }, + }); + expect(result1.exitCode).toBe(0); + + // Confirm only the specified secrets were exported + expect(result1.stdout).toInclude(`${secretName1}='${secretData1}'`); + expect(result1.stdout).toInclude(`${secretName2}='${secretData2}'`); + + // Run command with the invalid schema + const command2 = [ + 'secrets', + 'env', + '-np', + dataDir, + '--env-format', + 'unix', + '--egress-schema', + schema2Path, + vaultName, + ]; + const result2 = await testUtils.pkExec(command2, { + env: { PK_PASSWORD: password }, + }); + expect(result2.exitCode).toBe(78); + + // Confirm the error matches expectations + expect(result2.stderr).toContain('ErrorPolykeyCLISchemaInvalid'); + expect(result2.stderr).toContain('SECRET1'); + expect(result2.stderr).toContain('must be number'); + }); + + test('should validate with composed schemas', async () => { + // Write secrets to vault + const vaultName = 'vault'; + const vaultId = await polykeyAgent.vaultManager.createVault(vaultName); + const secretName1 = 'SECRET1'; + const secretName2 = 'SECRET2'; + await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => { + await vaultOps.addSecret(vault, secretName1, secretName1); + await vaultOps.addSecret(vault, secretName2, secretName2); + }); + + // Write schema to file. Schema 1 should be valid, and schema 2 should + // fail validation of the secrets. + const schema1Path = path.join(dataDir, 'egress1.schema.json'); + const schema2Path = path.join(dataDir, 'egress2.schema.json'); + const schema1 = { + allOf: [{ $ref: schema2Path }], + type: 'object', + properties: { + SECRET1: { type: 'string' }, + }, + required: ['SECRET1'], + }; + const schema2 = { + type: 'object', + properties: { + SECRET2: { type: 'number' }, + }, + required: ['SECRET2'], + }; + await fs.promises.writeFile( + schema1Path, + JSON.stringify(schema1, null, 2), + ); + await fs.promises.writeFile( + schema2Path, + JSON.stringify(schema2, null, 2), + ); + + // The command can only fail if it read the second schema and realised + // that SECRET2 is a string and is meant to be a number. + const command = [ + 'secrets', + 'env', + '-np', + dataDir, + '--env-format', + 'unix', + '--egress-schema', + schema1Path, + vaultName, + ]; + const result = await testUtils.pkExec(command, { + env: { PK_PASSWORD: password }, + }); + + // Confirm the error matches expectations + expect(result.exitCode).toBe(78); + expect(result.stderr).toContain('ErrorPolykeyCLISchemaInvalid'); + expect(result.stderr).toContain('SECRET2'); + expect(result.stderr).toContain('must be number'); + }); + }); });