Skip to content

Commit b3c6f90

Browse files
committed
feat: compositional env validation
1 parent 09cdc52 commit b3c6f90

File tree

9 files changed

+179
-3
lines changed

9 files changed

+179
-3
lines changed

npmDepsHash

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
sha256-/SFcicpWX1SFllzmz0acUYxtIV0LuBw1PU32Uk5hLzw=
1+
sha256-5lr9ukYvA7s8sAgnX5rN6ef12tOb9lPwAHGlOrD0k+w=

package-lock.json

Lines changed: 24 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@
136136
"sodium-native": "*"
137137
},
138138
"devDependencies": {
139+
"@apidevtools/json-schema-ref-parser": "^11.9.3",
139140
"@fast-check/jest": "^2.1.1",
140141
"@matrixai/errors": "^2.1.3",
141142
"@matrixai/exec": "^1.0.3",

ref.schema.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"type": "object",
3+
"allOf": [
4+
{ "$ref": "./test.schema.json"}
5+
],
6+
"properties": {
7+
"SECRET2": {
8+
"type": "string"
9+
}
10+
},
11+
"required": [
12+
"SECRET2"
13+
]
14+
}

src/secrets/CommandEnv.ts

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import type PolykeyClient from 'polykey/PolykeyClient.js';
2-
import type { ParsedSecretPathValue } from '../types.js';
2+
import type { JSONSchema, ParsedSecretPathValue } from '../types.js';
33
import path from 'node:path';
44
import os from 'node:os';
5+
import $RefParser from '@apidevtools/json-schema-ref-parser';
6+
import { Ajv2019 as Ajv } from 'ajv/dist/2019.js';
57
import { InvalidArgumentError } from 'commander';
68
import * as utils from 'polykey/utils/index.js';
79
import CommandPolykey from '../CommandPolykey.js';
@@ -26,6 +28,7 @@ class CommandEnv extends CommandPolykey {
2628
this.addOption(binOptions.envDuplicate);
2729
this.addOption(binOptions.envExport);
2830
this.addOption(binOptions.preserveNewline);
31+
this.addOption(binOptions.egressSchema);
2932
this.argument(
3033
'<args...>',
3134
'command and arguments formatted as <envPaths...> [-- cmd [cmdArgs...]]',
@@ -53,6 +56,7 @@ class CommandEnv extends CommandPolykey {
5356
if (secretPath == null) preservedSecrets.add(vaultName);
5457
else preservedSecrets.add(`${vaultName}:${secretPath}`);
5558
}
59+
5660
// There are a few stages here
5761
// 1. parse the desired secrets
5862
// 2. obtain the desired secrets
@@ -226,7 +230,50 @@ class CommandEnv extends CommandPolykey {
226230
};
227231
}
228232
await writeP;
229-
return [envp, envpPath];
233+
234+
// Apply validation using the schema
235+
// TODO: filter before pulling instead of after
236+
const filteredEnvp: Record<string, string> = {};
237+
if (options.egressSchema != null) {
238+
// Resolve references and bundle schema
239+
const schema: JSONSchema = await $RefParser.bundle(
240+
options.egressSchema,
241+
);
242+
243+
// Validate the incoming secrets against the schema
244+
const ajv = new Ajv({
245+
coerceTypes: true,
246+
useDefaults: false,
247+
allErrors: true,
248+
});
249+
const validate = ajv.compile(schema);
250+
validate(envp);
251+
252+
// Extract relevant keys, discarding the rest
253+
const { requiredKeys, allKeys, defaults } =
254+
binUtils.loadSchema(schema);
255+
256+
for (const key of allKeys) {
257+
let value = envp[key];
258+
if (value == null && defaults[key] != null) {
259+
value = defaults[key];
260+
}
261+
if (
262+
requiredKeys.includes(key) &&
263+
(value == null || value === '')
264+
) {
265+
throw new Error('TMP missing required variable');
266+
}
267+
if (value != null) {
268+
filteredEnvp[key] = value.toString();
269+
}
270+
}
271+
}
272+
273+
return [
274+
utils.isEmptyObject(filteredEnvp) ? envp : filteredEnvp,
275+
envpPath,
276+
];
230277
}, meta);
231278
// End connection early to avoid errors on server
232279
await pkClient.stop();

src/types.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,32 @@ type PromiseDeconstructed<T> = {
6363

6464
type ParsedSecretPathValue = [string, string?, string?];
6565

66+
type JSONSchemaProps = Record<
67+
string,
68+
{
69+
type?: string;
70+
default?: unknown;
71+
[key: string]: unknown;
72+
}
73+
>;
74+
75+
type JSONSchema = {
76+
type?: string;
77+
properties?: Record<string, JSONSchemaProps>;
78+
required?: Array<string>;
79+
allOf?: Array<JSONSchema>;
80+
anyOf?: Array<JSONSchema>;
81+
oneOf?: Array<JSONSchema>;
82+
[key: string]: unknown;
83+
};
84+
85+
// Only strings are supported for the time being
86+
type JSONSchemaInfo = {
87+
allKeys: Array<string>;
88+
requiredKeys: Array<string>;
89+
defaults: Record<string, string>;
90+
};
91+
6692
export type {
6793
TableRow,
6894
TableOptions,
@@ -72,4 +98,7 @@ export type {
7298
AgentChildProcessOutput,
7399
PromiseDeconstructed,
74100
ParsedSecretPathValue,
101+
JSONSchemaProps,
102+
JSONSchema,
103+
JSONSchemaInfo,
75104
};

src/utils/options.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,11 @@ const returnURLPath = new Option(
325325
'Which path on the website to send the token to',
326326
);
327327

328+
const egressSchema = new Option(
329+
'--egress-schema <path>',
330+
'A JSON schema controlling the egressing secrets',
331+
);
332+
328333
export {
329334
nodePath,
330335
format,
@@ -371,4 +376,5 @@ export {
371376
parents,
372377
preserveNewline,
373378
returnURLPath,
379+
egressSchema,
374380
};

src/utils/utils.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import type {
55
TableOptions,
66
DictOptions,
77
PromiseDeconstructed,
8+
JSONSchema,
9+
JSONSchemaInfo,
810
} from '../types.js';
911
import process from 'node:process';
1012
import { LogLevel } from '@matrixai/logger';
@@ -646,6 +648,47 @@ function jsonToCompactJWT(token: SignedTokenEncoded): string {
646648
return `${token.signatures[0].protected}.${token.payload}.${token.signatures[0].signature}`;
647649
}
648650

651+
function loadSchema(bundledSchema: JSONSchema): JSONSchemaInfo {
652+
const props: Set<string> = new Set();
653+
const required: Set<string> = new Set();
654+
const defaults: Record<string, string> = {};
655+
656+
const unwrapSchema = (schema: JSONSchema) => {
657+
// Collect properties and their defaults
658+
if (schema.properties != null) {
659+
for (const [k, p] of Object.entries(schema.properties)) {
660+
props.add(k);
661+
if (p.default == null) continue;
662+
if (typeof p.default !== 'string') {
663+
throw new Error('TMP wrong type');
664+
}
665+
defaults[k] = p.default;
666+
}
667+
}
668+
669+
// Collect required properties
670+
if (schema.required != null) {
671+
schema.required.forEach((requiredSecret) => required.add(requiredSecret));
672+
}
673+
674+
// Process composition keywords
675+
const compositionKeywords = ['allOf', 'anyOf', 'oneOf'] as const;
676+
compositionKeywords.forEach(
677+
(keyword) =>
678+
schema[keyword] != null &&
679+
Array.isArray(schema[keyword]) &&
680+
schema[keyword]!.forEach(unwrapSchema),
681+
);
682+
};
683+
unwrapSchema(bundledSchema);
684+
685+
return {
686+
allKeys: [...props],
687+
requiredKeys: [...required],
688+
defaults: defaults,
689+
};
690+
}
691+
649692
export {
650693
verboseToLogLevel,
651694
standardErrorReplacer,
@@ -670,6 +713,7 @@ export {
670713
promise,
671714
importFS,
672715
jsonToCompactJWT,
716+
loadSchema,
673717
};
674718

675719
export type { OutputObject };

test.schema.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"type": "object",
3+
"properties": {
4+
"SECRET1": {
5+
"type": "string"
6+
}
7+
},
8+
"required": [
9+
"SECRET1"
10+
]
11+
}

0 commit comments

Comments
 (0)