@indigoai-us/hq-cli 5.53.1 → 5.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.54.0]
6
+
7
+ ### Added
8
+
9
+ - **`hq secrets share/unshare … @all` — share a secret with the entire company.**
10
+ `--with @all` (share) and `--from @all` (unshare) now grant or revoke a
11
+ company-wide secret ACL — every active member of the company receives the
12
+ permission — instead of being refused. Output reads `@all (entire company)`.
13
+ Backed by vault-service company-wide ACL support; secrets with no explicit ACL
14
+ remain open to all members as before, so `@all` matters for re-opening a
15
+ restricted secret to the whole company.
16
+
5
17
  ## [5.53.1]
6
18
 
7
19
  ### Changed
@@ -1,5 +1,5 @@
1
1
 
2
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="96461f1a-6d5b-5dc0-b00a-0a9fa30a0dad")}catch(e){}}();
2
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="1dfcad18-3626-5c68-8b4c-0c147e896113")}catch(e){}}();
3
3
  import chalk from "chalk";
4
4
  import * as readline from "node:readline";
5
5
  import { spawn } from "node:child_process";
@@ -7,7 +7,7 @@ import * as nodePath from "node:path";
7
7
  import { ensureCognitoToken } from "../utils/cognito-session.js";
8
8
  import { DEFAULT_SECRETS_CACHE_TTL_MS, readCache, writeCache, removeCacheEntry, clearAllCache, } from "../utils/secrets-cache.js";
9
9
  import { computeSha256 } from "../utils/integrity.js";
10
- import { SECRET_NAME_PATTERN, GROUP_ID_PATTERN } from "./_patterns.js";
10
+ import { SECRET_NAME_PATTERN, GROUP_ID_PATTERN, EMAIL_PATTERN } from "./_patterns.js";
11
11
  import { describeSecretsScope, formatSecretSaved, formatSecretsListEmpty, formatSecretsListHeader, } from "./secrets-scope.js";
12
12
  import { vaultApiFetch, getCompanyUid, getEntityUid, } from "../utils/vault-api.js";
13
13
  export { vaultApiFetch, getCompanyUid, getEntityUid };
@@ -131,6 +131,27 @@ function promptSecretInteractively() {
131
131
  // large --only list is chunked client-side rather than 400'd whole by the
132
132
  // server (the legacy per-key GET path had no such cap).
133
133
  const MAX_BATCH_NAMES = 100;
134
+ function parseSecretAclPrincipal(principal) {
135
+ const p = principal.trim();
136
+ if (p === "@all") {
137
+ // Company-wide grant: every active member of the company. The server
138
+ // carries no specific grantee id for this axis, so granteeId is empty.
139
+ return { granteeType: "company-wide", granteeId: "" };
140
+ }
141
+ if (EMAIL_PATTERN.test(p)) {
142
+ return { granteeType: "email", granteeId: p.toLowerCase() };
143
+ }
144
+ if (GROUP_ID_PATTERN.test(p)) {
145
+ return { granteeType: "group", granteeId: p };
146
+ }
147
+ console.error(chalk.red(`Invalid principal '${principal}': must be '@all', an email address, or a group id matching grp_<alphanumeric, underscore, hyphen>`));
148
+ return null;
149
+ }
150
+ function describeSecretAclPrincipal(principal) {
151
+ return principal.granteeType === "company-wide"
152
+ ? "@all (entire company)"
153
+ : principal.granteeId;
154
+ }
134
155
  function normalizeSecretTier(tier) {
135
156
  return tier === "sensitive" || tier === "nuclear" ? tier : "standard";
136
157
  }
@@ -960,8 +981,8 @@ export function registerSecretsCommand(program) {
960
981
  });
961
982
  secrets
962
983
  .command("share <path>")
963
- .description("Share a secret with a person or group")
964
- .requiredOption("--with <principal>", "Email address or group id to share with")
984
+ .description("Share a secret with an email address, group, or the entire company (@all)")
985
+ .requiredOption("--with <principal>", "Email address, group id, or @all (entire company) to share with")
965
986
  .requiredOption("--permission <level>", "Permission level: read | write | admin")
966
987
  .action(async (path, opts) => {
967
988
  try {
@@ -974,20 +995,22 @@ export function registerSecretsCommand(program) {
974
995
  console.error(chalk.red(`Invalid permission '${opts.permission}': must be one of read, write, admin`));
975
996
  process.exit(1);
976
997
  }
977
- const isEmail = /^[^\s]+@[^\s]+$/.test(opts.with);
978
- if (!isEmail && !GROUP_ID_PATTERN.test(opts.with)) {
979
- console.error(chalk.red(`Invalid principal '${opts.with}': must be an email address or a group id matching grp_<alphanumeric>`));
998
+ const principal = parseSecretAclPrincipal(opts.with);
999
+ if (!principal) {
980
1000
  process.exit(1);
981
1001
  }
982
- const granteeType = isEmail ? "email" : "group";
983
- const granteeId = opts.with;
984
1002
  const token = await ensureCognitoToken();
985
1003
  const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
986
1004
  const res = await vaultApiFetch({
987
1005
  token,
988
1006
  path: `/secrets/${encodeURIComponent(companyUid)}/acl/grant`,
989
1007
  method: "POST",
990
- body: { path, granteeType, granteeId, permission: opts.permission },
1008
+ body: {
1009
+ path,
1010
+ granteeType: principal.granteeType,
1011
+ granteeId: principal.granteeId,
1012
+ permission: opts.permission,
1013
+ },
991
1014
  });
992
1015
  if (!res.ok) {
993
1016
  const body = await res.json().catch(() => ({}));
@@ -1011,7 +1034,7 @@ export function registerSecretsCommand(program) {
1011
1034
  }
1012
1035
  process.exit(1);
1013
1036
  }
1014
- console.log(chalk.green(`Shared '${path}' with ${opts.with} (${opts.permission})`));
1037
+ console.log(chalk.green(`Shared '${path}' with ${describeSecretAclPrincipal(principal)} (${opts.permission})`));
1015
1038
  }
1016
1039
  catch (err) {
1017
1040
  console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
@@ -1021,7 +1044,7 @@ export function registerSecretsCommand(program) {
1021
1044
  secrets
1022
1045
  .command("unshare <path>")
1023
1046
  .description("Remove a grant from a secret")
1024
- .requiredOption("--from <principal>", "Email address or group id to remove")
1047
+ .requiredOption("--from <principal>", "Email address, group id, or @all (entire company) to remove")
1025
1048
  .action(async (path, opts) => {
1026
1049
  try {
1027
1050
  rejectIfPersonal(secrets.opts(), "unshare");
@@ -1029,20 +1052,21 @@ export function registerSecretsCommand(program) {
1029
1052
  console.error(chalk.red(`Invalid secret path '${path}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$ (e.g. MY_KEY or PROD/DB_PASSWORD)`));
1030
1053
  process.exit(1);
1031
1054
  }
1032
- const isEmailFrom = /^[^\s]+@[^\s]+$/.test(opts.from);
1033
- if (!isEmailFrom && !GROUP_ID_PATTERN.test(opts.from)) {
1034
- console.error(chalk.red(`Invalid principal '${opts.from}': must be an email address or a group id matching grp_<alphanumeric>`));
1055
+ const principal = parseSecretAclPrincipal(opts.from);
1056
+ if (!principal) {
1035
1057
  process.exit(1);
1036
1058
  }
1037
- const granteeType = isEmailFrom ? "email" : "group";
1038
- const granteeId = opts.from;
1039
1059
  const token = await ensureCognitoToken();
1040
1060
  const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
1041
1061
  const res = await vaultApiFetch({
1042
1062
  token,
1043
1063
  path: `/secrets/${encodeURIComponent(companyUid)}/acl/revoke`,
1044
1064
  method: "POST",
1045
- body: { path, granteeType, granteeId },
1065
+ body: {
1066
+ path,
1067
+ granteeType: principal.granteeType,
1068
+ granteeId: principal.granteeId,
1069
+ },
1046
1070
  });
1047
1071
  if (!res.ok) {
1048
1072
  const body = await res.json().catch(() => ({}));
@@ -1053,7 +1077,7 @@ export function registerSecretsCommand(program) {
1053
1077
  console.error(chalk.red("Not authorized to modify this secret's ACL"));
1054
1078
  }
1055
1079
  else if (res.status === 404) {
1056
- console.log(chalk.green(`Grant already absent for '${path}' / ${opts.from}`));
1080
+ console.log(chalk.green(`Grant already absent for '${path}' / ${describeSecretAclPrincipal(principal)}`));
1057
1081
  return;
1058
1082
  }
1059
1083
  else if (res.status >= 500) {
@@ -1064,7 +1088,7 @@ export function registerSecretsCommand(program) {
1064
1088
  }
1065
1089
  process.exit(1);
1066
1090
  }
1067
- console.log(chalk.green(`Removed grant for ${opts.from} on '${path}'`));
1091
+ console.log(chalk.green(`Removed grant for ${describeSecretAclPrincipal(principal)} on '${path}'`));
1068
1092
  }
1069
1093
  catch (err) {
1070
1094
  console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
@@ -1163,4 +1187,4 @@ export function registerSecretsCommand(program) {
1163
1187
  });
1164
1188
  }
1165
1189
  //# sourceMappingURL=secrets.js.map
1166
- //# debugId=96461f1a-6d5b-5dc0-b00a-0a9fa30a0dad
1190
+ //# debugId=1dfcad18-3626-5c68-8b4c-0c147e896113
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.53.1",
3
+ "version": "5.54.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -273,6 +273,92 @@ describe("secrets generate-link", () => {
273
273
  });
274
274
  });
275
275
 
276
+ describe("secrets share ACL principals", () => {
277
+ it("grants a secret to a group principal", async () => {
278
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(jsonRes({ ok: true }));
279
+
280
+ const program = buildProgram();
281
+ await program.parseAsync([
282
+ "node",
283
+ "hq",
284
+ "secrets",
285
+ "share",
286
+ "MY_KEY",
287
+ "--with",
288
+ "grp_ops",
289
+ "--permission",
290
+ "read",
291
+ ]);
292
+
293
+ expect(vaultApiFetch).toHaveBeenCalledWith({
294
+ token: "test-token",
295
+ path: "/secrets/prs_alice/acl/grant",
296
+ method: "POST",
297
+ body: {
298
+ path: "MY_KEY",
299
+ granteeType: "group",
300
+ granteeId: "grp_ops",
301
+ permission: "read",
302
+ },
303
+ });
304
+ });
305
+
306
+ it("grants a secret company-wide with @all", async () => {
307
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(jsonRes({ ok: true }));
308
+
309
+ const program = buildProgram();
310
+ await program.parseAsync([
311
+ "node",
312
+ "hq",
313
+ "secrets",
314
+ "share",
315
+ "MY_KEY",
316
+ "--with",
317
+ "@all",
318
+ "--permission",
319
+ "read",
320
+ ]);
321
+
322
+ expect(vaultApiFetch).toHaveBeenCalledWith({
323
+ token: "test-token",
324
+ path: "/secrets/prs_alice/acl/grant",
325
+ method: "POST",
326
+ body: {
327
+ path: "MY_KEY",
328
+ granteeType: "company-wide",
329
+ granteeId: "",
330
+ permission: "read",
331
+ },
332
+ });
333
+ });
334
+
335
+ it("revokes a company-wide grant with @all", async () => {
336
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(jsonRes({ ok: true }));
337
+
338
+ const program = buildProgram();
339
+ await program.parseAsync([
340
+ "node",
341
+ "hq",
342
+ "secrets",
343
+ "unshare",
344
+ "MY_KEY",
345
+ "--from",
346
+ "@all",
347
+ ]);
348
+
349
+ expect(vaultApiFetch).toHaveBeenCalledWith({
350
+ token: "test-token",
351
+ path: "/secrets/prs_alice/acl/revoke",
352
+ method: "POST",
353
+ body: {
354
+ path: "MY_KEY",
355
+ granteeType: "company-wide",
356
+ granteeId: "",
357
+ },
358
+ });
359
+ });
360
+ });
361
+
276
362
  // HQ-4H — `hq secrets exec`/`env` load secrets through the BATCH-LOAD endpoint
277
363
  // (`POST /secrets/{uid}/load`), which returns 200 + per-name `errors[]` and
278
364
  // never fires the server's "Secret not found" Sentry warning. The old per-key
@@ -12,7 +12,7 @@ import {
12
12
  clearAllCache,
13
13
  } from "../utils/secrets-cache.js";
14
14
  import { computeSha256 } from "../utils/integrity.js";
15
- import { SECRET_NAME_PATTERN, GROUP_ID_PATTERN } from "./_patterns.js";
15
+ import { SECRET_NAME_PATTERN, GROUP_ID_PATTERN, EMAIL_PATTERN } from "./_patterns.js";
16
16
  import {
17
17
  describeSecretsScope,
18
18
  formatSecretSaved,
@@ -240,6 +240,40 @@ interface SecretPolicyResponse {
240
240
  scripts?: SecretPolicyScript[];
241
241
  }
242
242
 
243
+ interface SecretAclPrincipal {
244
+ granteeType: "email" | "group" | "company-wide";
245
+ granteeId: string;
246
+ }
247
+
248
+ function parseSecretAclPrincipal(
249
+ principal: string,
250
+ ): SecretAclPrincipal | null {
251
+ const p = principal.trim();
252
+ if (p === "@all") {
253
+ // Company-wide grant: every active member of the company. The server
254
+ // carries no specific grantee id for this axis, so granteeId is empty.
255
+ return { granteeType: "company-wide", granteeId: "" };
256
+ }
257
+ if (EMAIL_PATTERN.test(p)) {
258
+ return { granteeType: "email", granteeId: p.toLowerCase() };
259
+ }
260
+ if (GROUP_ID_PATTERN.test(p)) {
261
+ return { granteeType: "group", granteeId: p };
262
+ }
263
+ console.error(
264
+ chalk.red(
265
+ `Invalid principal '${principal}': must be '@all', an email address, or a group id matching grp_<alphanumeric, underscore, hyphen>`,
266
+ ),
267
+ );
268
+ return null;
269
+ }
270
+
271
+ function describeSecretAclPrincipal(principal: SecretAclPrincipal): string {
272
+ return principal.granteeType === "company-wide"
273
+ ? "@all (entire company)"
274
+ : principal.granteeId;
275
+ }
276
+
243
277
  function normalizeSecretTier(tier?: string): SecretTier {
244
278
  return tier === "sensitive" || tier === "nuclear" ? tier : "standard";
245
279
  }
@@ -1365,8 +1399,8 @@ export function registerSecretsCommand(program: Command): void {
1365
1399
 
1366
1400
  secrets
1367
1401
  .command("share <path>")
1368
- .description("Share a secret with a person or group")
1369
- .requiredOption("--with <principal>", "Email address or group id to share with")
1402
+ .description("Share a secret with an email address, group, or the entire company (@all)")
1403
+ .requiredOption("--with <principal>", "Email address, group id, or @all (entire company) to share with")
1370
1404
  .requiredOption("--permission <level>", "Permission level: read | write | admin")
1371
1405
  .action(async (path: string, opts: { with: string; permission: string }) => {
1372
1406
  try {
@@ -1382,13 +1416,10 @@ export function registerSecretsCommand(program: Command): void {
1382
1416
  process.exit(1);
1383
1417
  }
1384
1418
 
1385
- const isEmail = /^[^\s]+@[^\s]+$/.test(opts.with);
1386
- if (!isEmail && !GROUP_ID_PATTERN.test(opts.with)) {
1387
- console.error(chalk.red(`Invalid principal '${opts.with}': must be an email address or a group id matching grp_<alphanumeric>`));
1419
+ const principal = parseSecretAclPrincipal(opts.with);
1420
+ if (!principal) {
1388
1421
  process.exit(1);
1389
1422
  }
1390
- const granteeType = isEmail ? "email" : "group";
1391
- const granteeId = opts.with;
1392
1423
 
1393
1424
  const token = await ensureCognitoToken();
1394
1425
  const companyUid = await getEntityUid(
@@ -1400,7 +1431,12 @@ export function registerSecretsCommand(program: Command): void {
1400
1431
  token,
1401
1432
  path: `/secrets/${encodeURIComponent(companyUid)}/acl/grant`,
1402
1433
  method: "POST",
1403
- body: { path, granteeType, granteeId, permission: opts.permission },
1434
+ body: {
1435
+ path,
1436
+ granteeType: principal.granteeType,
1437
+ granteeId: principal.granteeId,
1438
+ permission: opts.permission,
1439
+ },
1404
1440
  });
1405
1441
 
1406
1442
  if (!res.ok) {
@@ -1421,7 +1457,7 @@ export function registerSecretsCommand(program: Command): void {
1421
1457
  process.exit(1);
1422
1458
  }
1423
1459
 
1424
- console.log(chalk.green(`Shared '${path}' with ${opts.with} (${opts.permission})`));
1460
+ console.log(chalk.green(`Shared '${path}' with ${describeSecretAclPrincipal(principal)} (${opts.permission})`));
1425
1461
  } catch (err) {
1426
1462
  console.error(
1427
1463
  chalk.red("Error:"),
@@ -1434,7 +1470,7 @@ export function registerSecretsCommand(program: Command): void {
1434
1470
  secrets
1435
1471
  .command("unshare <path>")
1436
1472
  .description("Remove a grant from a secret")
1437
- .requiredOption("--from <principal>", "Email address or group id to remove")
1473
+ .requiredOption("--from <principal>", "Email address, group id, or @all (entire company) to remove")
1438
1474
  .action(async (path: string, opts: { from: string }) => {
1439
1475
  try {
1440
1476
  rejectIfPersonal(secrets.opts(), "unshare");
@@ -1444,13 +1480,10 @@ export function registerSecretsCommand(program: Command): void {
1444
1480
  process.exit(1);
1445
1481
  }
1446
1482
 
1447
- const isEmailFrom = /^[^\s]+@[^\s]+$/.test(opts.from);
1448
- if (!isEmailFrom && !GROUP_ID_PATTERN.test(opts.from)) {
1449
- console.error(chalk.red(`Invalid principal '${opts.from}': must be an email address or a group id matching grp_<alphanumeric>`));
1483
+ const principal = parseSecretAclPrincipal(opts.from);
1484
+ if (!principal) {
1450
1485
  process.exit(1);
1451
1486
  }
1452
- const granteeType = isEmailFrom ? "email" : "group";
1453
- const granteeId = opts.from;
1454
1487
 
1455
1488
  const token = await ensureCognitoToken();
1456
1489
  const companyUid = await getEntityUid(
@@ -1462,7 +1495,11 @@ export function registerSecretsCommand(program: Command): void {
1462
1495
  token,
1463
1496
  path: `/secrets/${encodeURIComponent(companyUid)}/acl/revoke`,
1464
1497
  method: "POST",
1465
- body: { path, granteeType, granteeId },
1498
+ body: {
1499
+ path,
1500
+ granteeType: principal.granteeType,
1501
+ granteeId: principal.granteeId,
1502
+ },
1466
1503
  });
1467
1504
 
1468
1505
  if (!res.ok) {
@@ -1472,7 +1509,7 @@ export function registerSecretsCommand(program: Command): void {
1472
1509
  } else if (res.status === 403) {
1473
1510
  console.error(chalk.red("Not authorized to modify this secret's ACL"));
1474
1511
  } else if (res.status === 404) {
1475
- console.log(chalk.green(`Grant already absent for '${path}' / ${opts.from}`));
1512
+ console.log(chalk.green(`Grant already absent for '${path}' / ${describeSecretAclPrincipal(principal)}`));
1476
1513
  return;
1477
1514
  } else if (res.status >= 500) {
1478
1515
  console.error(chalk.red(`Server error: ${body.error ?? res.statusText}`));
@@ -1482,7 +1519,7 @@ export function registerSecretsCommand(program: Command): void {
1482
1519
  process.exit(1);
1483
1520
  }
1484
1521
 
1485
- console.log(chalk.green(`Removed grant for ${opts.from} on '${path}'`));
1522
+ console.log(chalk.green(`Removed grant for ${describeSecretAclPrincipal(principal)} on '${path}'`));
1486
1523
  } catch (err) {
1487
1524
  console.error(
1488
1525
  chalk.red("Error:"),