@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.
- package/dist/apps/cli/src/apps/catenv/catenv.js +3 -0
- package/dist/apps/cli/src/apps/catenv/catenv.js.map +1 -1
- package/dist/apps/cli/src/apps/cli/commands/project/doctor/GcloudProjectInspector.d.ts +4 -0
- package/dist/apps/cli/src/apps/cli/commands/project/doctor/GcloudProjectInspector.js +12 -0
- package/dist/apps/cli/src/apps/cli/commands/project/doctor/GcloudProjectInspector.js.map +1 -1
- package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkCloudRun.d.ts +2 -2
- package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkCloudRun.js +3 -1
- package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkCloudRun.js.map +1 -1
- package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkLocalAccess.d.ts +10 -0
- package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkLocalAccess.js +98 -0
- package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkLocalAccess.js.map +1 -0
- package/dist/apps/cli/src/apps/cli/commands/project/doctor/index.js +1 -1
- package/dist/apps/cli/src/apps/cli/commands/project/doctor/index.js.map +1 -1
- package/dist/apps/cli/src/apps/cli/commands/project/setup/setupContext.js +2 -0
- package/dist/apps/cli/src/apps/cli/commands/project/setup/setupContext.js.map +1 -1
- package/dist/apps/cli/src/apps/cli/commands/project/setup/setupLocalAccess.d.ts +9 -0
- package/dist/apps/cli/src/apps/cli/commands/project/setup/setupLocalAccess.js +31 -0
- package/dist/apps/cli/src/apps/cli/commands/project/setup/setupLocalAccess.js.map +1 -0
- package/dist/apps/cli/src/config/getProjectConfig.js +9 -1
- package/dist/apps/cli/src/config/getProjectConfig.js.map +1 -1
- package/dist/apps/cli/src/gcloud/localAccess.d.ts +53 -0
- package/dist/apps/cli/src/gcloud/localAccess.js +176 -0
- package/dist/apps/cli/src/gcloud/localAccess.js.map +1 -0
- package/dist/apps/cli/src/gcloud/serviceAccounts.js +9 -2
- package/dist/apps/cli/src/gcloud/serviceAccounts.js.map +1 -1
- package/dist/apps/cli/src/localAccess/declared.d.ts +14 -0
- package/dist/apps/cli/src/localAccess/declared.js +34 -0
- package/dist/apps/cli/src/localAccess/declared.js.map +1 -0
- package/dist/apps/cli/src/localAccess/keyFiles.d.ts +28 -0
- package/dist/apps/cli/src/localAccess/keyFiles.js +91 -0
- package/dist/apps/cli/src/localAccess/keyFiles.js.map +1 -0
- package/dist/apps/cli/src/utils/writePrivateFile.d.ts +7 -0
- package/dist/apps/cli/src/utils/writePrivateFile.js +25 -0
- package/dist/apps/cli/src/utils/writePrivateFile.js.map +1 -0
- package/dist/apps/cli/src/vault/BitwardenVault.js +13 -22
- package/dist/apps/cli/src/vault/BitwardenVault.js.map +1 -1
- package/dist/apps/cli/src/vault/VaultManager.d.ts +6 -0
- package/dist/apps/cli/src/vault/VaultManager.js +18 -0
- package/dist/apps/cli/src/vault/VaultManager.js.map +1 -1
- package/dist/apps/cli/src/vault/bw.d.ts +6 -2
- package/dist/apps/cli/src/vault/bw.js +32 -6
- package/dist/apps/cli/src/vault/bw.js.map +1 -1
- package/dist/apps/cli/src/vault/cache.js +5 -2
- package/dist/apps/cli/src/vault/cache.js.map +1 -1
- package/dist/bundles/catenv/index.js +436 -31
- package/dist/bundles/cli/index.js +786 -35
- package/dist/bundles/skills/catladder-secrets/SKILL.md +35 -3
- package/dist/packages/pipeline/src/index.d.ts +1 -0
- package/dist/packages/pipeline/src/index.js +1 -0
- package/dist/packages/pipeline/src/index.js.map +1 -1
- package/dist/packages/pipeline/src/localAccess/index.d.ts +53 -0
- package/dist/packages/pipeline/src/localAccess/index.js +177 -0
- package/dist/packages/pipeline/src/localAccess/index.js.map +1 -0
- package/dist/packages/pipeline/src/types/config.d.ts +75 -0
- package/dist/packages/pipeline/src/types/config.js.map +1 -1
- package/dist/skills/catladder-secrets/SKILL.md +35 -3
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/apps/catenv/catenv.ts +4 -0
- package/src/apps/cli/commands/project/doctor/GcloudProjectInspector.ts +25 -1
- package/src/apps/cli/commands/project/doctor/__tests__/checkLocalAccess.test.ts +127 -0
- package/src/apps/cli/commands/project/doctor/checkCloudRun.ts +5 -1
- package/src/apps/cli/commands/project/doctor/checkLocalAccess.ts +146 -0
- package/src/apps/cli/commands/project/doctor/index.ts +1 -1
- package/src/apps/cli/commands/project/setup/setupContext.ts +2 -0
- package/src/apps/cli/commands/project/setup/setupLocalAccess.ts +44 -0
- package/src/config/__tests__/getEnvVarsResolved.test.ts +45 -0
- package/src/config/getProjectConfig.ts +11 -1
- package/src/gcloud/__tests__/localAccess.test.ts +82 -0
- package/src/gcloud/__tests__/syncLocalAccess.test.ts +196 -0
- package/src/gcloud/localAccess.ts +257 -0
- package/src/gcloud/serviceAccounts.ts +8 -2
- package/src/localAccess/__tests__/keyFiles.test.ts +166 -0
- package/src/localAccess/declared.ts +38 -0
- package/src/localAccess/keyFiles.ts +126 -0
- package/src/utils/__tests__/writePrivateFile.test.ts +40 -0
- package/src/utils/writePrivateFile.ts +20 -0
- package/src/vault/BitwardenVault.ts +8 -9
- package/src/vault/VaultManager.ts +28 -0
- package/src/vault/__tests__/BitwardenVault.test.ts +4 -4
- package/src/vault/__tests__/cache.test.ts +37 -0
- package/src/vault/bw.ts +34 -9
- package/src/vault/cache.ts +5 -2
package/package.json
CHANGED
|
@@ -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 = {
|
|
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
|
-
|
|
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
|
+
});
|