Skip to content

Commit e9abc78

Browse files
committed
feat: added schema compositionality and tests
[ci skip]
1 parent f3520f1 commit e9abc78

File tree

4 files changed

+313
-18
lines changed

4 files changed

+313
-18
lines changed

src/errors.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,11 @@ class ErrorPolykeyCLIDuplicateEnvName<T> extends ErrorPolykeyCLI<T> {
166166
exitCode = sysexits.USAGE;
167167
}
168168

169+
class ErrorPolykeyCLIMissingRequiredEnvName<T> extends ErrorPolykeyCLI<T> {
170+
static description = 'A required environment variable is not present';
171+
exitCode = sysexits.USAGE;
172+
}
173+
169174
class ErrorPolykeyCLIMakeDirectory<T> extends ErrorPolykeyCLI<T> {
170175
static description = 'Failed to create one or more directories';
171176
exitCode = 1;
@@ -223,6 +228,7 @@ export {
223228
ErrorPolykeyCLINodePingFailed,
224229
ErrorPolykeyCLIInvalidEnvName,
225230
ErrorPolykeyCLIDuplicateEnvName,
231+
ErrorPolykeyCLIMissingRequiredEnvName,
226232
ErrorPolykeyCLIMakeDirectory,
227233
ErrorPolykeyCLIRenameSecret,
228234
ErrorPolykeyCLIRemoveSecret,

src/secrets/CommandEnv.ts

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ class CommandEnv extends CommandPolykey {
172172
if (allKeys.includes(name)) {
173173
await writer.write({
174174
nameOrId: nameOrId,
175-
secretName: name,
175+
secretName: secretName!,
176176
metadata: first ? auth : undefined,
177177
});
178178
}
@@ -201,25 +201,18 @@ class CommandEnv extends CommandPolykey {
201201
if (value.type === 'ErrorMessage') {
202202
switch (value.code) {
203203
case 'EINVAL':
204-
// It is expected for the data to be populated with the offending
205-
// vault name if the vault was not found.
204+
// It is expected for the data to be populated with the
205+
// offending vault name if the vault was not found.
206206
throw new Error(
207207
`TMP Vault "${value.data?.nameOrId}" does not exist`,
208208
);
209209
case 'ENOENT':
210-
// If we have a default for this key, then don't bother
211-
// reporting the missing key.
212-
if (
213-
unwrappedSchema != null &&
214-
Object.keys(unwrappedSchema.defaults).includes(
215-
value.data!.secretName!.toString(),
216-
)
217-
) {
218-
break;
219-
}
210+
// If we are working with schemas, then missing keys will be
211+
// validated later.
212+
if (unwrappedSchema != null) break;
220213

221-
// It is expected for the data to be populated with the offending
222-
// secret and vault name if a secret was not found.
214+
// It is expected for the data to be populated with the
215+
// offending secret and vault name if a secret was not found.
223216
throw new Error(
224217
`TMP Secret "${value.data?.secretName}" does not exist in vault "${value.data?.nameOrId}"`,
225218
);
@@ -310,7 +303,7 @@ class CommandEnv extends CommandPolykey {
310303

311304
// Apply defaults using the schema
312305
const filteredEnvp: Record<string, string> = {};
313-
if (unwrappedSchema != null) {
306+
if (schema != null && unwrappedSchema != null) {
314307
// Parse the schema for manual filtering
315308
const { requiredKeys, allKeys, defaults } = unwrappedSchema;
316309

@@ -326,12 +319,20 @@ class CommandEnv extends CommandPolykey {
326319
requiredKeys.includes(key) &&
327320
(value == null || value === '')
328321
) {
329-
throw new Error('TMP missing required variable');
322+
throw new binErrors.ErrorPolykeyCLIMissingRequiredEnvName(
323+
`Expected definition for ${key}`,
324+
);
330325
}
331326
if (value != null) {
332327
filteredEnvp[key] = value.toString();
333328
}
334329
}
330+
331+
// Validate the schema using ajv. All defaults have already been
332+
// applied. This is now the final state of the exported variables.
333+
const ajv = new Ajv({ allErrors: true });
334+
const validate = ajv.compile(schema);
335+
validate(envp);
335336
}
336337

337338
return [

src/utils/utils.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -457,7 +457,9 @@ function outputFormatterError(err: any): string {
457457
output += '\n';
458458
}
459459
}
460-
output += `${indent}cause: `;
460+
if (err.cause) {
461+
output += `${indent}cause: `;
462+
}
461463
err = err.cause;
462464
} else if (err instanceof ErrorPolykey) {
463465
output += `${err.name}: ${err.description}`;

tests/secrets/env.test.ts

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -953,4 +953,290 @@ describe('commandEnv', () => {
953953
expect(result.stdout).toEqual(formatResult[format]);
954954
},
955955
);
956+
957+
describe('should apply json schema to control secrets egress', () => {
958+
test('should filter secrets based on a schema', async () => {
959+
// Write secrets to vault
960+
const vaultName = 'vault';
961+
const vaultId = await polykeyAgent.vaultManager.createVault(vaultName);
962+
const secretName1 = 'SECRET1';
963+
const secretName2 = 'SECRET2';
964+
const secretName3 = 'SECRET3';
965+
await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => {
966+
await vaultOps.addSecret(vault, secretName1, secretName1);
967+
await vaultOps.addSecret(vault, secretName2, secretName2);
968+
await vaultOps.addSecret(vault, secretName3, secretName3);
969+
});
970+
971+
// Write schema to file
972+
const schema = {
973+
type: 'object',
974+
properties: {
975+
SECRET1: { type: 'string' },
976+
SECRET2: { type: 'string' },
977+
},
978+
required: ['SECRET1', 'SECRET2'],
979+
};
980+
const schemaPath = path.join(dataDir, 'egress.schema.json');
981+
await fs.promises.writeFile(schemaPath, JSON.stringify(schema, null, 2));
982+
983+
// Run command with the schema
984+
const command = [
985+
'secrets',
986+
'env',
987+
'-np',
988+
dataDir,
989+
'--env-format',
990+
'unix',
991+
'--egress-schema',
992+
schemaPath,
993+
vaultName,
994+
];
995+
const result = await testUtils.pkExec(command, {
996+
env: { PK_PASSWORD: password },
997+
});
998+
expect(result.exitCode).toBe(0);
999+
1000+
// Confirm only the specified secrets were exported
1001+
expect(result.stdout).toContain("SECRET1='SECRET1'");
1002+
expect(result.stdout).toContain("SECRET2='SECRET2'");
1003+
expect(result.stdout).not.toContain("SECRET3='SECRET3'");
1004+
});
1005+
1006+
test('should apply schema to secrets from multiple vaults', async () => {
1007+
// Write secrets to vault
1008+
const vaultName1 = 'vault1';
1009+
const vaultName2 = 'vault2';
1010+
const vaultId1 = await polykeyAgent.vaultManager.createVault(vaultName1);
1011+
const vaultId2 = await polykeyAgent.vaultManager.createVault(vaultName2);
1012+
const secretName1 = 'SECRET1';
1013+
const secretName2 = 'SECRET2';
1014+
const secretName3 = 'SECRET3';
1015+
await polykeyAgent.vaultManager.withVaults(
1016+
[vaultId1, vaultId2],
1017+
async (vault1, vault2) => {
1018+
await vaultOps.addSecret(vault1, secretName1, secretName1);
1019+
await vaultOps.addSecret(vault2, secretName2, secretName2);
1020+
await vaultOps.addSecret(vault1, secretName3, secretName3);
1021+
},
1022+
);
1023+
1024+
// Write schema to file
1025+
const schema = {
1026+
type: 'object',
1027+
properties: {
1028+
SECRET1: { type: 'string' },
1029+
SECRET2: { type: 'string' },
1030+
},
1031+
required: ['SECRET1', 'SECRET2'],
1032+
};
1033+
const schemaPath = path.join(dataDir, 'egress.schema.json');
1034+
await fs.promises.writeFile(schemaPath, JSON.stringify(schema, null, 2));
1035+
1036+
// Run command with the schema
1037+
const command = [
1038+
'secrets',
1039+
'env',
1040+
'-np',
1041+
dataDir,
1042+
'--env-format',
1043+
'unix',
1044+
'--egress-schema',
1045+
schemaPath,
1046+
vaultName1,
1047+
vaultName2,
1048+
];
1049+
const result = await testUtils.pkExec(command, {
1050+
env: { PK_PASSWORD: password },
1051+
});
1052+
expect(result.exitCode).toBe(0);
1053+
1054+
// Confirm only the specified secrets were exported
1055+
expect(result.stdout).toContain("SECRET1='SECRET1'");
1056+
expect(result.stdout).toContain("SECRET2='SECRET2'");
1057+
expect(result.stdout).not.toContain("SECRET3='SECRET3'");
1058+
});
1059+
1060+
test('should handle secret renames', async () => {
1061+
// Write secrets to vault
1062+
const vaultName = 'vault';
1063+
const vaultId = await polykeyAgent.vaultManager.createVault(vaultName);
1064+
const secretName1 = 'SECRET1';
1065+
const secretName2 = 'SECRET2';
1066+
const secretName3 = 'SECRET3';
1067+
const secretRename = 'RENAMED';
1068+
await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => {
1069+
await vaultOps.addSecret(vault, secretName1, secretName1);
1070+
await vaultOps.addSecret(vault, secretName2, secretName2);
1071+
await vaultOps.addSecret(vault, secretName3, secretName3);
1072+
});
1073+
1074+
// Write schema to file
1075+
const schema = {
1076+
type: 'object',
1077+
properties: {
1078+
SECRET1: { type: 'string' },
1079+
RENAMED: { type: 'string' },
1080+
},
1081+
required: ['SECRET1', 'RENAMED'],
1082+
};
1083+
const schemaPath = path.join(dataDir, 'egress.schema.json');
1084+
await fs.promises.writeFile(schemaPath, JSON.stringify(schema, null, 2));
1085+
1086+
// Run command with the schema. Should first export all relevant secrets
1087+
// from the vault, then process the renamed secret.
1088+
const command = [
1089+
'secrets',
1090+
'env',
1091+
'-np',
1092+
dataDir,
1093+
'--env-format',
1094+
'unix',
1095+
'--egress-schema',
1096+
schemaPath,
1097+
vaultName,
1098+
`${vaultName}:${secretName2}=${secretRename}`,
1099+
];
1100+
const result = await testUtils.pkExec(command, {
1101+
env: { PK_PASSWORD: password },
1102+
});
1103+
expect(result.exitCode).toBe(0);
1104+
1105+
// Confirm only the specified secrets were exported
1106+
expect(result.stdout).toContain("SECRET1='SECRET1'");
1107+
expect(result.stdout).toContain("RENAMED='SECRET2'");
1108+
expect(result.stdout).not.toContain("SECRET2='SECRET2'");
1109+
expect(result.stdout).not.toContain("SECRET3='SECRET3'");
1110+
});
1111+
1112+
test('should not fail when missing non-required secret', async () => {
1113+
// Write secrets to vault
1114+
const vaultName = 'vault';
1115+
const vaultId = await polykeyAgent.vaultManager.createVault(vaultName);
1116+
const secretName1 = 'SECRET1';
1117+
await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => {
1118+
await vaultOps.addSecret(vault, secretName1, secretName1);
1119+
});
1120+
1121+
// Write schema to file
1122+
const schema = {
1123+
type: 'object',
1124+
properties: {
1125+
SECRET1: { type: 'string' },
1126+
SECRET2: { type: 'string' },
1127+
},
1128+
required: ['SECRET1'],
1129+
};
1130+
const schemaPath = path.join(dataDir, 'egress.schema.json');
1131+
await fs.promises.writeFile(schemaPath, JSON.stringify(schema, null, 2));
1132+
1133+
// Run command with the schema. Should first export all relevant secrets
1134+
// from the vault, then process the renamed secret.
1135+
const command = [
1136+
'secrets',
1137+
'env',
1138+
'-np',
1139+
dataDir,
1140+
'--env-format',
1141+
'unix',
1142+
'--egress-schema',
1143+
schemaPath,
1144+
vaultName,
1145+
];
1146+
const result = await testUtils.pkExec(command, {
1147+
env: { PK_PASSWORD: password },
1148+
});
1149+
expect(result.exitCode).toBe(0);
1150+
1151+
// Confirm only the specified secrets were exported
1152+
expect(result.stdout).toContain("SECRET1='SECRET1'");
1153+
expect(result.stdout).not.toContain('SECRET2');
1154+
});
1155+
1156+
test('should fail when missing required secret', async () => {
1157+
// Write secrets to vault
1158+
const vaultName = 'vault';
1159+
const vaultId = await polykeyAgent.vaultManager.createVault(vaultName);
1160+
const secretName1 = 'SECRET1';
1161+
await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => {
1162+
await vaultOps.addSecret(vault, secretName1, secretName1);
1163+
});
1164+
1165+
// Write schema to file
1166+
const schema = {
1167+
type: 'object',
1168+
properties: {
1169+
SECRET1: { type: 'string' },
1170+
SECRET2: { type: 'string' },
1171+
},
1172+
required: ['SECRET1', 'SECRET2'],
1173+
};
1174+
const schemaPath = path.join(dataDir, 'egress.schema.json');
1175+
await fs.promises.writeFile(schemaPath, JSON.stringify(schema, null, 2));
1176+
1177+
// Run command with the schema
1178+
const command = [
1179+
'secrets',
1180+
'env',
1181+
'-np',
1182+
dataDir,
1183+
'--env-format',
1184+
'unix',
1185+
'--egress-schema',
1186+
schemaPath,
1187+
vaultName,
1188+
];
1189+
const result = await testUtils.pkExec(command, {
1190+
env: { PK_PASSWORD: password },
1191+
});
1192+
expect(result.exitCode).toBe(64);
1193+
1194+
// Confirm the validity of the error
1195+
expect(result.stderr).toInclude('ErrorPolykeyCLIMissingRequiredEnvName');
1196+
expect(result.stderr).toInclude('SECRET2');
1197+
});
1198+
1199+
test('should replace variable with default if present', async () => {
1200+
// Write secrets to vault
1201+
const vaultName = 'vault';
1202+
const vaultId = await polykeyAgent.vaultManager.createVault(vaultName);
1203+
const secretName1 = 'SECRET1';
1204+
await polykeyAgent.vaultManager.withVaults([vaultId], async (vault) => {
1205+
await vaultOps.addSecret(vault, secretName1, secretName1);
1206+
});
1207+
1208+
// Write schema to file
1209+
const schema = {
1210+
type: 'object',
1211+
properties: {
1212+
SECRET1: { type: 'string' },
1213+
SECRET2: { type: 'string', default: 'abc' },
1214+
},
1215+
required: ['SECRET1', 'SECRET2'],
1216+
};
1217+
const schemaPath = path.join(dataDir, 'egress.schema.json');
1218+
await fs.promises.writeFile(schemaPath, JSON.stringify(schema, null, 2));
1219+
1220+
// Run command with the schema
1221+
const command = [
1222+
'secrets',
1223+
'env',
1224+
'-np',
1225+
dataDir,
1226+
'--env-format',
1227+
'unix',
1228+
'--egress-schema',
1229+
schemaPath,
1230+
vaultName,
1231+
];
1232+
const result = await testUtils.pkExec(command, {
1233+
env: { PK_PASSWORD: password },
1234+
});
1235+
expect(result.exitCode).toBe(0);
1236+
1237+
// Confirm only the specified secrets were exported
1238+
expect(result.stdout).toInclude("SECRET1='SECRET1'");
1239+
expect(result.stdout).toInclude("SECRET2='abc");
1240+
});
1241+
});
9561242
});

0 commit comments

Comments
 (0)