@catladder/cli 0.0.0-main-2355fd7c → 0.0.0-main-018e2dee

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.
Files changed (83) hide show
  1. package/dist/apps/cli/src/apps/catenv/catenv.js +3 -0
  2. package/dist/apps/cli/src/apps/catenv/catenv.js.map +1 -1
  3. package/dist/apps/cli/src/apps/cli/commands/project/doctor/GcloudProjectInspector.d.ts +4 -0
  4. package/dist/apps/cli/src/apps/cli/commands/project/doctor/GcloudProjectInspector.js +12 -0
  5. package/dist/apps/cli/src/apps/cli/commands/project/doctor/GcloudProjectInspector.js.map +1 -1
  6. package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkCloudRun.d.ts +2 -2
  7. package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkCloudRun.js +3 -1
  8. package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkCloudRun.js.map +1 -1
  9. package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkLocalAccess.d.ts +10 -0
  10. package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkLocalAccess.js +98 -0
  11. package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkLocalAccess.js.map +1 -0
  12. package/dist/apps/cli/src/apps/cli/commands/project/doctor/index.js +1 -1
  13. package/dist/apps/cli/src/apps/cli/commands/project/doctor/index.js.map +1 -1
  14. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupContext.js +2 -0
  15. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupContext.js.map +1 -1
  16. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupLocalAccess.d.ts +9 -0
  17. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupLocalAccess.js +31 -0
  18. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupLocalAccess.js.map +1 -0
  19. package/dist/apps/cli/src/config/getProjectConfig.js +9 -1
  20. package/dist/apps/cli/src/config/getProjectConfig.js.map +1 -1
  21. package/dist/apps/cli/src/gcloud/localAccess.d.ts +53 -0
  22. package/dist/apps/cli/src/gcloud/localAccess.js +176 -0
  23. package/dist/apps/cli/src/gcloud/localAccess.js.map +1 -0
  24. package/dist/apps/cli/src/gcloud/serviceAccounts.js +9 -2
  25. package/dist/apps/cli/src/gcloud/serviceAccounts.js.map +1 -1
  26. package/dist/apps/cli/src/localAccess/declared.d.ts +14 -0
  27. package/dist/apps/cli/src/localAccess/declared.js +34 -0
  28. package/dist/apps/cli/src/localAccess/declared.js.map +1 -0
  29. package/dist/apps/cli/src/localAccess/keyFiles.d.ts +28 -0
  30. package/dist/apps/cli/src/localAccess/keyFiles.js +91 -0
  31. package/dist/apps/cli/src/localAccess/keyFiles.js.map +1 -0
  32. package/dist/apps/cli/src/utils/writePrivateFile.d.ts +7 -0
  33. package/dist/apps/cli/src/utils/writePrivateFile.js +25 -0
  34. package/dist/apps/cli/src/utils/writePrivateFile.js.map +1 -0
  35. package/dist/apps/cli/src/vault/BitwardenVault.js +13 -22
  36. package/dist/apps/cli/src/vault/BitwardenVault.js.map +1 -1
  37. package/dist/apps/cli/src/vault/VaultManager.d.ts +6 -0
  38. package/dist/apps/cli/src/vault/VaultManager.js +18 -0
  39. package/dist/apps/cli/src/vault/VaultManager.js.map +1 -1
  40. package/dist/apps/cli/src/vault/bw.d.ts +6 -2
  41. package/dist/apps/cli/src/vault/bw.js +32 -6
  42. package/dist/apps/cli/src/vault/bw.js.map +1 -1
  43. package/dist/apps/cli/src/vault/cache.js +5 -2
  44. package/dist/apps/cli/src/vault/cache.js.map +1 -1
  45. package/dist/bundles/catenv/index.js +436 -31
  46. package/dist/bundles/cli/index.js +786 -35
  47. package/dist/bundles/skills/catladder-secrets/SKILL.md +35 -3
  48. package/dist/packages/pipeline/src/index.d.ts +1 -0
  49. package/dist/packages/pipeline/src/index.js +1 -0
  50. package/dist/packages/pipeline/src/index.js.map +1 -1
  51. package/dist/packages/pipeline/src/localAccess/index.d.ts +53 -0
  52. package/dist/packages/pipeline/src/localAccess/index.js +177 -0
  53. package/dist/packages/pipeline/src/localAccess/index.js.map +1 -0
  54. package/dist/packages/pipeline/src/types/config.d.ts +75 -0
  55. package/dist/packages/pipeline/src/types/config.js.map +1 -1
  56. package/dist/skills/catladder-secrets/SKILL.md +35 -3
  57. package/dist/tsconfig.tsbuildinfo +1 -1
  58. package/package.json +1 -1
  59. package/src/apps/catenv/catenv.ts +4 -0
  60. package/src/apps/cli/commands/project/doctor/GcloudProjectInspector.ts +25 -1
  61. package/src/apps/cli/commands/project/doctor/__tests__/checkLocalAccess.test.ts +127 -0
  62. package/src/apps/cli/commands/project/doctor/checkCloudRun.ts +5 -1
  63. package/src/apps/cli/commands/project/doctor/checkLocalAccess.ts +146 -0
  64. package/src/apps/cli/commands/project/doctor/index.ts +1 -1
  65. package/src/apps/cli/commands/project/setup/setupContext.ts +2 -0
  66. package/src/apps/cli/commands/project/setup/setupLocalAccess.ts +44 -0
  67. package/src/config/__tests__/getEnvVarsResolved.test.ts +45 -0
  68. package/src/config/getProjectConfig.ts +11 -1
  69. package/src/gcloud/__tests__/localAccess.test.ts +82 -0
  70. package/src/gcloud/__tests__/syncLocalAccess.test.ts +196 -0
  71. package/src/gcloud/localAccess.ts +257 -0
  72. package/src/gcloud/serviceAccounts.ts +8 -2
  73. package/src/localAccess/__tests__/keyFiles.test.ts +166 -0
  74. package/src/localAccess/declared.ts +38 -0
  75. package/src/localAccess/keyFiles.ts +126 -0
  76. package/src/utils/__tests__/writePrivateFile.test.ts +40 -0
  77. package/src/utils/writePrivateFile.ts +20 -0
  78. package/src/vault/BitwardenVault.ts +8 -9
  79. package/src/vault/VaultManager.ts +28 -0
  80. package/src/vault/__tests__/BitwardenVault.test.ts +4 -4
  81. package/src/vault/__tests__/cache.test.ts +37 -0
  82. package/src/vault/bw.ts +34 -9
  83. package/src/vault/cache.ts +5 -2
package/package.json CHANGED
@@ -53,7 +53,7 @@
53
53
  }
54
54
  ],
55
55
  "license": "MIT",
56
- "version": "0.0.0-main-2355fd7c",
56
+ "version": "0.0.0-main-018e2dee",
57
57
  "scripts": {
58
58
  "lint": "eslint \"src/**/*.ts\"",
59
59
  "lint:fix": "eslint \"src/**/*.ts\" --fix",
@@ -1,4 +1,5 @@
1
1
  import { getProjectConfig } from "../../config/getProjectConfig";
2
+ import { writeLocalAccessKeyFiles } from "../../localAccess/keyFiles";
2
3
  import { printVariables } from "./printVariables";
3
4
  import type { Choice } from "./types";
4
5
  import { printVerboseBanner } from "./verboseBanner";
@@ -42,6 +43,9 @@ export default async (choice?: Choice, options?: Options) => {
42
43
  printVerboseBanner();
43
44
  }
44
45
 
46
+ // before the env vars are resolved: they point at the key files
47
+ await writeLocalAccessKeyFiles(io, config);
48
+
45
49
  await Promise.all([
46
50
  generatePipelineFiles(context),
47
51
  writeDotEnvFiles(context, choice),
@@ -1,7 +1,11 @@
1
1
  import { exec } from "child-process-promise";
2
2
  import { CATLADDER_REGISTRY_NAME } from "../../../../../gcloud/artifactsRegistry";
3
3
 
4
- type IamBinding = { role: string; members?: string[] };
4
+ type IamBinding = {
5
+ role: string;
6
+ members?: string[];
7
+ condition?: unknown;
8
+ };
5
9
 
6
10
  /**
7
11
  * read-only gcloud queries for the doctor, cached per project: checking
@@ -41,6 +45,26 @@ export class GcloudProjectInspector {
41
45
  .map((binding) => binding.role);
42
46
  }
43
47
 
48
+ /** roles bound to the member without an iam condition */
49
+ async getUnconditionalRolesOfMember(
50
+ projectId: string,
51
+ member: string,
52
+ ): Promise<string[]> {
53
+ return (await this.getIamBindings(projectId))
54
+ .filter((b) => !b.condition && b.members?.includes(member))
55
+ .map((b) => b.role);
56
+ }
57
+
58
+ /** roles bound to the member under an iam condition */
59
+ async getConditionalRolesOfMember(
60
+ projectId: string,
61
+ member: string,
62
+ ): Promise<string[]> {
63
+ return (await this.getIamBindings(projectId))
64
+ .filter((b) => b.condition && b.members?.includes(member))
65
+ .map((b) => b.role);
66
+ }
67
+
44
68
  getEnabledServices(projectId: string): Promise<Set<string>> {
45
69
  if (!this.enabledServices.has(projectId)) {
46
70
  this.enabledServices.set(
@@ -0,0 +1,127 @@
1
+ import type { ComponentContext, Config } from "@catladder/pipeline";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ const state = { exists: true, keys: ["k1"] as string[] };
5
+ vi.mock("../../../../../../gcloud/serviceAccounts", () => ({
6
+ accountExists: async () => state.exists,
7
+ }));
8
+ vi.mock("../../../../../../gcloud/localAccess", async (orig) => ({
9
+ ...(await orig<object>()),
10
+ listUserManagedKeyIds: async () => state.keys,
11
+ }));
12
+
13
+ import { checkLocalAccess } from "../checkLocalAccess";
14
+
15
+ const cloudRun = { type: "google-cloudrun", projectId: "p", region: "r" };
16
+
17
+ const context = {
18
+ env: "dev",
19
+ name: "web",
20
+ fullConfig: { customerName: "acme", appName: "shop" },
21
+ deploy: { config: cloudRun },
22
+ } as unknown as ComponentContext;
23
+
24
+ const config = (extra: Partial<Config> = {}) =>
25
+ ({
26
+ appName: "shop",
27
+ customerName: "acme",
28
+ components: { web: { dir: ".", build: false, deploy: cloudRun } },
29
+ localAccess: { gcp: { presets: ["logs"] } },
30
+ secrets: { vault: { type: "bitwarden" } },
31
+ ...extra,
32
+ }) as Config;
33
+
34
+ const run = async (
35
+ cfg: Config,
36
+ bound: string[] = ["roles/logging.viewer"],
37
+ conditional: string[] = [],
38
+ ) => {
39
+ const out: string[] = [];
40
+ const report = {
41
+ section: (t: string) => out.push(`section:${t}`),
42
+ ok: (m: string) => out.push(`ok:${m}`),
43
+ warn: (m: string) => out.push(`warn:${m}`),
44
+ fail: (m: string) => out.push(`fail:${m}`),
45
+ } as any;
46
+ const inspector = {
47
+ getUnconditionalRolesOfMember: async () => bound,
48
+ getConditionalRolesOfMember: async () => conditional,
49
+ } as any;
50
+ await checkLocalAccess(report, cfg, [context], inspector);
51
+ return out;
52
+ };
53
+
54
+ beforeEach(() => {
55
+ state.exists = true;
56
+ state.keys = ["k1"];
57
+ });
58
+
59
+ describe("checkLocalAccess", () => {
60
+ it("is silent without a declaration and without an account", async () => {
61
+ state.exists = false;
62
+ expect(await run(config({ localAccess: undefined }))).toEqual([]);
63
+ });
64
+
65
+ it("is ok when roles and key match", async () => {
66
+ const out = await run(config());
67
+ expect(out.some((l) => l.startsWith("ok:"))).toBe(true);
68
+ expect(out.some((l) => /^(fail|warn):/.test(l))).toBe(false);
69
+ });
70
+
71
+ it("reports missing and extra roles", async () => {
72
+ const out = await run(config(), ["roles/run.admin"]);
73
+ expect(out.join("\n")).toMatch(
74
+ /fail:.*missing roles: roles\/logging.viewer/,
75
+ );
76
+ expect(out.join("\n")).toMatch(/fail:.*undeclared roles: roles\/run.admin/);
77
+ });
78
+
79
+ it("reports conditional roles", async () => {
80
+ const out = await run(config(), ["roles/logging.viewer"], ["roles/x.y"]);
81
+ expect(out.join("\n")).toMatch(/fail:.*conditional roles/);
82
+ });
83
+
84
+ it("reports a missing account", async () => {
85
+ state.exists = false;
86
+ expect((await run(config())).join("\n")).toMatch(/fail:.*does not exist/);
87
+ });
88
+
89
+ it("warns about an account that is not declared any more", async () => {
90
+ expect((await run(config({ localAccess: undefined }))).join("\n")).toMatch(
91
+ /warn:.*exists although no local access is declared/,
92
+ );
93
+ });
94
+
95
+ it.each([[[]], [["k1", "k2"]]])("reports %j keys", async (keys) => {
96
+ state.keys = keys;
97
+ expect((await run(config())).join("\n")).toMatch(
98
+ new RegExp(`fail:.*${keys.length} user-managed keys`),
99
+ );
100
+ });
101
+
102
+ it("refuses the gitlab vault", async () => {
103
+ const out = await run(config({ secrets: { vault: "gitlab" } } as any));
104
+ expect(out.join("\n")).toMatch(/fail:.*not a CI variable store/);
105
+ });
106
+
107
+ it("reports an invalid declaration instead of throwing", async () => {
108
+ const out = await run(
109
+ config({ localAccess: { gcp: { roles: ["roles/owner"] } } }),
110
+ );
111
+ expect(out.join("\n")).toMatch(/fail:.*not allowed/);
112
+ });
113
+
114
+ it("reports a credentialsEnvVar exported by several envs", async () => {
115
+ const out = await run(
116
+ config({
117
+ localAccess: {
118
+ gcp: {
119
+ presets: ["logs"],
120
+ credentialsEnvVar: "GOOGLE_APPLICATION_CREDENTIALS",
121
+ },
122
+ },
123
+ }),
124
+ );
125
+ expect(out.join("\n")).toMatch(/fail:.*declared by both/);
126
+ });
127
+ });
@@ -1,4 +1,4 @@
1
- import type { ComponentContext } from "@catladder/pipeline";
1
+ import type { ComponentContext, Config } from "@catladder/pipeline";
2
2
  import { isOfDeployType } from "@catladder/pipeline";
3
3
  import { CATLADDER_REGISTRY_NAME } from "../../../../../gcloud/artifactsRegistry";
4
4
  import { getGcloudServiceAccountNames } from "../../../../../gcloud/serviceAccountNames";
@@ -9,6 +9,7 @@ import {
9
9
  getCloudRunDeployRoles,
10
10
  getCloudRunRequiredServices,
11
11
  } from "../setup/setupCloudRun";
12
+ import { checkLocalAccess } from "./checkLocalAccess";
12
13
  import type { DoctorReport } from "./DoctorReport";
13
14
  import { GcloudProjectInspector } from "./GcloudProjectInspector";
14
15
 
@@ -33,6 +34,7 @@ const getCloudRunConfig = (context: ComponentContext) => {
33
34
  export const checkCloudRun = async (
34
35
  report: DoctorReport,
35
36
  contexts: ComponentContext[],
37
+ config: Config,
36
38
  ) => {
37
39
  const cloudRunContexts = contexts.filter((context) =>
38
40
  isOfDeployType(context.deploy?.config, "google-cloudrun"),
@@ -145,4 +147,6 @@ export const checkCloudRun = async (
145
147
  );
146
148
  }
147
149
  }
150
+
151
+ await checkLocalAccess(report, config, cloudRunContexts, inspector);
148
152
  };
@@ -0,0 +1,146 @@
1
+ import type { ComponentContext, Config } from "@catladder/pipeline";
2
+ import {
3
+ diffRoleBindings,
4
+ getLocalAccessGcpCredentialsEnvVars,
5
+ getLocalAccessGcpRoles,
6
+ getVaultConfig,
7
+ isOfDeployType,
8
+ } from "@catladder/pipeline";
9
+ import {
10
+ getLocalAccessServiceAccountNames,
11
+ listUserManagedKeyIds,
12
+ } from "../../../../../gcloud/localAccess";
13
+ import { accountExists } from "../../../../../gcloud/serviceAccounts";
14
+ import { mapWithConcurrency } from "../../../../../utils/concurrency";
15
+ import type { DoctorReport } from "./DoctorReport";
16
+ import type { GcloudProjectInspector } from "./GcloudProjectInspector";
17
+
18
+ /**
19
+ * compares the read-only local access service accounts (`localAccess`
20
+ * in catladder.ts) with their declaration: missing account, missing and
21
+ * extra bindings, and an account that exists although nothing is
22
+ * declared any more. Expects an authenticated inspector.
23
+ */
24
+ export const checkLocalAccess = async (
25
+ report: DoctorReport,
26
+ config: Config,
27
+ contexts: ComponentContext[],
28
+ inspector: GcloudProjectInspector,
29
+ ) => {
30
+ const results = await mapWithConcurrency(contexts, 5, async (context) => {
31
+ const deployConfig = context.deploy?.config;
32
+ if (!isOfDeployType(deployConfig, "google-cloudrun")) return null;
33
+ const label = `${context.env}:${context.name}`;
34
+ let declared: string[];
35
+ try {
36
+ declared = getLocalAccessGcpRoles(config, context.env);
37
+ getLocalAccessGcpCredentialsEnvVars(config, context.name);
38
+ } catch (e) {
39
+ return { label, invalid: e.message as string };
40
+ }
41
+ const { fullIdentifier } = getLocalAccessServiceAccountNames(
42
+ context,
43
+ deployConfig.projectId,
44
+ );
45
+ const exists = await accountExists(fullIdentifier);
46
+ const member = `serviceAccount:${fullIdentifier}`;
47
+ const bound = exists
48
+ ? await inspector.getUnconditionalRolesOfMember(
49
+ deployConfig.projectId,
50
+ member,
51
+ )
52
+ : [];
53
+ const conditional = exists
54
+ ? await inspector.getConditionalRolesOfMember(
55
+ deployConfig.projectId,
56
+ member,
57
+ )
58
+ : [];
59
+ const keyCount = exists
60
+ ? (await listUserManagedKeyIds(fullIdentifier)).length
61
+ : 0;
62
+ return {
63
+ label,
64
+ declared,
65
+ fullIdentifier,
66
+ exists,
67
+ bound,
68
+ conditional,
69
+ keyCount,
70
+ };
71
+ });
72
+
73
+ const relevant = results.filter(
74
+ (r) => r && ("invalid" in r || r.declared.length > 0 || r.exists),
75
+ );
76
+ if (relevant.length === 0) return;
77
+
78
+ report.section("local access (read-only service accounts)");
79
+ for (const result of relevant) {
80
+ if (!result) continue;
81
+ if ("invalid" in result) {
82
+ report.fail(`${result.label}: ${result.invalid}`);
83
+ continue;
84
+ }
85
+ const {
86
+ label,
87
+ declared,
88
+ fullIdentifier,
89
+ exists,
90
+ bound,
91
+ conditional,
92
+ keyCount,
93
+ } = result;
94
+ if (declared.length > 0 && getVaultConfig(config).type === "gitlab") {
95
+ report.fail(
96
+ `${label}: localAccess needs a vault that is not a CI variable store (the gitlab vault would expose the key to every CI job)`,
97
+ );
98
+ continue;
99
+ }
100
+ const heal = `run: catladder project setup`;
101
+ if (declared.length === 0) {
102
+ report.warn(
103
+ `${label}: ${fullIdentifier} exists although no local access is declared`,
104
+ heal,
105
+ );
106
+ } else if (!exists) {
107
+ report.fail(`${label}: ${fullIdentifier} does not exist`, heal);
108
+ } else {
109
+ const { missing, extra } = diffRoleBindings(declared, bound);
110
+ if (missing.length > 0) {
111
+ report.fail(
112
+ `${label}: local access account is missing roles: ${missing.join(", ")}`,
113
+ heal,
114
+ );
115
+ }
116
+ if (extra.length > 0) {
117
+ report.fail(
118
+ `${label}: local access account has undeclared roles: ${extra.join(", ")}`,
119
+ heal,
120
+ );
121
+ }
122
+ if (conditional.length > 0) {
123
+ report.fail(
124
+ `${label}: local access account has conditional roles (${conditional.join(", ")}) that setup does not remove`,
125
+ "remove them by hand",
126
+ );
127
+ }
128
+ if (keyCount !== 1) {
129
+ report.fail(
130
+ `${label}: local access account has ${keyCount} user-managed keys, expected exactly 1`,
131
+ heal,
132
+ );
133
+ }
134
+ if (
135
+ missing.length === 0 &&
136
+ extra.length === 0 &&
137
+ conditional.length === 0 &&
138
+ keyCount === 1
139
+ ) {
140
+ report.ok(
141
+ `${label}: local access account has exactly the declared roles`,
142
+ );
143
+ }
144
+ }
145
+ }
146
+ };
@@ -54,7 +54,7 @@ export const doctorProject = async (
54
54
  if (getEnabledPipelineTypes(config).includes("gitlab")) {
55
55
  await checkGitlab(instance, report, contexts);
56
56
  }
57
- await checkCloudRun(report, contexts);
57
+ await checkCloudRun(report, contexts, config);
58
58
  await checkGithubImageCasing(report, config);
59
59
  await checkGithubReleaseWorkflow(report, config);
60
60
  await checkGithub(report, config);
@@ -2,6 +2,7 @@ import type { ComponentContext } from "@catladder/pipeline";
2
2
  import { isOfDeployType } from "@catladder/pipeline";
3
3
  import type { IO } from "../../../../../core/types";
4
4
  import { setupCloudRun } from "./setupCloudRun";
5
+ import { setupLocalAccess } from "./setupLocalAccess";
5
6
 
6
7
  import { setupKubernetes } from "./setupKubernetes";
7
8
  import { logSection } from "./logSection";
@@ -13,6 +14,7 @@ export const setupContext = async (instance: IO, context: ComponentContext) => {
13
14
  async () => {
14
15
  if (isOfDeployType(context.deploy?.config, "google-cloudrun")) {
15
16
  await setupCloudRun(instance, context);
17
+ await setupLocalAccess(instance, context);
16
18
  }
17
19
 
18
20
  const deployConfig = context.deploy?.config;
@@ -0,0 +1,44 @@
1
+ import type { ComponentContext } from "@catladder/pipeline";
2
+ import {
3
+ getLocalAccessGcpCredentialsEnvVars,
4
+ getLocalAccessGcpRoles,
5
+ getVaultConfig,
6
+ isOfDeployType,
7
+ } from "@catladder/pipeline";
8
+ import { getProjectConfig } from "../../../../../config/getProjectConfig";
9
+ import type { IO } from "../../../../../core/types";
10
+ import { syncLocalAccessServiceAccount } from "../../../../../gcloud/localAccess";
11
+
12
+ /**
13
+ * provisions the local access (`localAccess` in catladder.ts)
14
+ * of a google cloud run component/env: a dedicated service account with
15
+ * exactly the declared bindings and its key in the secret store. Also
16
+ * removes what is no longer declared.
17
+ */
18
+ export const setupLocalAccess = async (
19
+ instance: IO,
20
+ context: ComponentContext,
21
+ ) => {
22
+ const config = context.deploy?.config;
23
+ if (!isOfDeployType(config, "google-cloudrun")) return;
24
+ const projectConfig = await getProjectConfig();
25
+ const roles = getLocalAccessGcpRoles(projectConfig, context.env);
26
+ // validates `credentialsEnvVar` (catenv silently skips invalid ones)
27
+ getLocalAccessGcpCredentialsEnvVars(projectConfig, context.name);
28
+
29
+ if (roles.length > 0 && getVaultConfig(projectConfig).type === "gitlab") {
30
+ // the gitlab vault stores secrets as CI variables available to every
31
+ // job on every branch: the key would leak to anyone who can push
32
+ throw new Error(
33
+ `localAccess (${context.env}:${context.name}) needs a vault that is not a CI variable store (e.g. bitwarden): the gitlab vault would expose the service account key to every CI job. Configure \`vault\` or remove the localAccess declaration.`,
34
+ );
35
+ }
36
+
37
+ instance.log("sync local access service account...");
38
+ await syncLocalAccessServiceAccount(
39
+ instance,
40
+ context,
41
+ config.projectId,
42
+ roles,
43
+ );
44
+ };
@@ -0,0 +1,45 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const keyFileVars = vi.fn();
4
+ vi.mock("../../localAccess/keyFiles", () => ({
5
+ getLocalAccessKeyFileVars: (...args: unknown[]) => keyFileVars(...args),
6
+ }));
7
+ vi.mock("../../utils/projects", () => ({ getGitRoot: async () => "/repo" }));
8
+ vi.mock("@catladder/pipeline", async (orig) => ({
9
+ ...(await orig<object>()),
10
+ readConfig: async () => ({ config: { components: { web: {} } } }),
11
+ getEnvironment: async () => ({
12
+ envVars: { PLAIN: "value" },
13
+ secretEnvVarKeys: [],
14
+ }),
15
+ }));
16
+
17
+ import { getEnvVarsResolved } from "../getProjectConfig";
18
+
19
+ const io = {
20
+ getVaultManager: async () => ({ readSecrets: async () => ({}) }),
21
+ } as any;
22
+
23
+ beforeEach(() => {
24
+ keyFileVars.mockReset();
25
+ keyFileVars.mockResolvedValue({
26
+ CATLADDER_LOCAL_ACCESS_GCP_DEV_KEY_FILE: "/k",
27
+ });
28
+ });
29
+
30
+ describe("getEnvVarsResolved", () => {
31
+ it("adds the local access key file paths for the local env", async () => {
32
+ expect(await getEnvVarsResolved(io, "local", "web")).toEqual({
33
+ PLAIN: "value",
34
+ CATLADDER_LOCAL_ACCESS_GCP_DEV_KEY_FILE: "/k",
35
+ });
36
+ expect(keyFileVars).toHaveBeenCalledWith(expect.anything(), "web");
37
+ });
38
+
39
+ it("leaves other envs untouched", async () => {
40
+ expect(await getEnvVarsResolved(io, "dev", "web")).toEqual({
41
+ PLAIN: "value",
42
+ });
43
+ expect(keyFileVars).not.toHaveBeenCalled();
44
+ });
45
+ });
@@ -13,6 +13,7 @@ import {
13
13
  } from "@catladder/pipeline";
14
14
 
15
15
  import type { IO } from "../core/types";
16
+ import { getLocalAccessKeyFileVars } from "../localAccess/keyFiles";
16
17
  import { getVariableValueByRawName } from "../utils/gitlab";
17
18
 
18
19
  import { getGitRoot } from "../utils/projects";
@@ -191,12 +192,21 @@ export const getEnvVarsResolved = async (
191
192
 
192
193
  // in the pipeline the secrets alreadyy exists and bash will expand them
193
194
  // but here we need to manually load them
194
- return resolveSecrets(io, [
195
+ const resolved = await resolveSecrets(io, [
195
196
  {
196
197
  envVars: envionment.envVars,
197
198
  secretEnvVarKeys: envionment.secretEnvVarKeys,
198
199
  },
199
200
  ]);
201
+ if (env !== "local") return resolved;
202
+ // paths of the local access key files (written by catenv)
203
+ return {
204
+ ...resolved,
205
+ ...(await getLocalAccessKeyFileVars(
206
+ await getProjectConfig(),
207
+ componentName,
208
+ )),
209
+ };
200
210
  };
201
211
 
202
212
  /**
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { ComponentContext } from "@catladder/pipeline";
3
+ import {
4
+ getLocalAccessServiceAccountNames,
5
+ getConditionalRolesOfMemberInBindings,
6
+ getRolesOfMemberInBindings,
7
+ parseServiceAccountKey,
8
+ } from "../localAccess";
9
+
10
+ const context = {
11
+ env: "review",
12
+ name: "web",
13
+ fullConfig: { customerName: "acme", appName: "shop" },
14
+ } as ComponentContext;
15
+
16
+ describe("getLocalAccessServiceAccountNames", () => {
17
+ it("is deterministic, within the 30 char limit and distinct from the deploy account", () => {
18
+ const a = getLocalAccessServiceAccountNames(context, "some-project");
19
+ const b = getLocalAccessServiceAccountNames(context, "some-project");
20
+ expect(a).toEqual(b);
21
+ expect(a.fullName.length).toBeLessThanOrEqual(30);
22
+ expect(a.fullName.startsWith("local-diag-")).toBe(true);
23
+ expect(a.fullIdentifier).toBe(
24
+ `${a.fullName}@some-project.iam.gserviceaccount.com`,
25
+ );
26
+ });
27
+ });
28
+
29
+ describe("getRolesOfMemberInBindings", () => {
30
+ it("collects the sorted roles of exactly the given member", () => {
31
+ const m = "serviceAccount:a@p.iam.gserviceaccount.com";
32
+ expect(
33
+ getRolesOfMemberInBindings(
34
+ [
35
+ { role: "roles/logging.viewer", members: [m, "user:x@y.z"] },
36
+ { role: "roles/b.viewer", members: [m] },
37
+ { role: "roles/run.admin", members: ["user:x@y.z"] },
38
+ { role: "roles/other" },
39
+ ],
40
+ m,
41
+ ),
42
+ ).toEqual(["roles/b.viewer", "roles/logging.viewer"]);
43
+ });
44
+ });
45
+
46
+ describe("conditional bindings", () => {
47
+ const m = "serviceAccount:a@p.iam.gserviceaccount.com";
48
+ const bindings = [
49
+ { role: "roles/logging.viewer", members: [m] },
50
+ {
51
+ role: "roles/storage.objectViewer",
52
+ members: [m],
53
+ condition: { title: "t", expression: "true" },
54
+ },
55
+ ];
56
+ it("keeps them out of the plain roles and reports them separately", () => {
57
+ expect(getRolesOfMemberInBindings(bindings, m)).toEqual([
58
+ "roles/logging.viewer",
59
+ ]);
60
+ expect(getConditionalRolesOfMemberInBindings(bindings, m)).toEqual([
61
+ "roles/storage.objectViewer",
62
+ ]);
63
+ });
64
+ });
65
+
66
+ describe("parseServiceAccountKey", () => {
67
+ it("accepts a key and returns its id", () => {
68
+ const raw = JSON.stringify({ private_key_id: "abc", private_key: "x" });
69
+ expect(parseServiceAccountKey(raw)).toEqual({
70
+ key: raw,
71
+ privateKeyId: "abc",
72
+ });
73
+ });
74
+ it.each(["", "not json", "{}", '{"private_key_id":"a"}'])(
75
+ "rejects %j instead of storing it",
76
+ (raw) => {
77
+ expect(() => parseServiceAccountKey(raw)).toThrow(
78
+ /valid service account key/,
79
+ );
80
+ },
81
+ );
82
+ });