@nuvlore/extension-workday-infra-access 0.1.1
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/LICENSE +202 -0
- package/README.md +3 -0
- package/agents/workday-infra-access-readonly.yaml +183 -0
- package/package.json +50 -0
- package/skills/workday-infra-access/SKILL.md +102 -0
- package/skills/workday-infra-access/references/access-boundary-runbook.md +43 -0
- package/skills/workday-infra-access/references/access-runbook.md +15 -0
- package/skills/workday-infra-access/references/access-scylla-cloud-runbook.md +39 -0
- package/skills/workday-infra-access/references/access-spc-argo-runbook.md +99 -0
- package/src/workday-access-profiles.mjs +194 -0
- package/tools/aws_cust_federated_readonly_command_pack.mjs +234 -0
- package/tools/gcp_readonly_command_pack.mjs +167 -0
- package/tools/public_cloud_access_status.mjs +48 -0
- package/tools/workday_boundary_login.mjs +460 -0
- package/tools/workday_private_cloud_sudo_readonly_command.mjs +386 -0
- package/tools/workday_public_cloud_environment_map.mjs +189 -0
- package/tools/workday_public_cloud_kubernetes_read.mjs +297 -0
- package/tools/workday_public_cloud_spc_login.mjs +505 -0
- package/tools/workday_spc_argo_readonly_command_pack.mjs +215 -0
- package/vendor/extension-read-only-toolkit/LICENSE +202 -0
- package/vendor/extension-read-only-toolkit/README.md +29 -0
- package/vendor/extension-read-only-toolkit/package.json +46 -0
- package/vendor/extension-read-only-toolkit/src/devops-data-paths.mjs +222 -0
- package/vendor/extension-read-only-toolkit/src/devops-diagnostics.mjs +440 -0
- package/vendor/extension-read-only-toolkit/src/enterprise-api-read.mjs +180 -0
- package/vendor/extension-read-only-toolkit/src/read-only-command-pack.mjs +1 -0
- package/vendor/extension-read-only-toolkit/src/read-only-plan.mjs +45 -0
- package/vendor/extension-read-only-toolkit/src/read-only-shell-policy.d.mts +26 -0
- package/vendor/extension-read-only-toolkit/src/read-only-shell-policy.mjs +170 -0
- package/workflows/public-cloud-readonly-investigation.js +99 -0
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
export const description = "Plan, check, or start a local Workday public-cloud SPC login session for a specific tier and cluster using a saved macOS Keychain LDAP token. Use this for public cloud environments; private cloud/DC access stays on Boundary/BOS-AINAT login.";
|
|
2
|
+
|
|
3
|
+
import { execFile, spawn } from "node:child_process";
|
|
4
|
+
import { openSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { defineReadOnlyTool, fail, ok } from "@nuvlore/extension-read-only-toolkit/devops-diagnostics";
|
|
10
|
+
import {
|
|
11
|
+
DEFAULT_WORKDAY_LDAP_KEYCHAIN_ACCOUNT,
|
|
12
|
+
DEFAULT_WORKDAY_LDAP_KEYCHAIN_SERVICE,
|
|
13
|
+
DEFAULT_WORKDAY_LDAP_PROD_KEYCHAIN_ACCOUNT,
|
|
14
|
+
extractTokenFromKeychainSecret,
|
|
15
|
+
workdayProdCredentialCandidates,
|
|
16
|
+
shellQuote
|
|
17
|
+
} from "../src/workday-access-profiles.mjs";
|
|
18
|
+
|
|
19
|
+
const execFileAsync = promisify(execFile);
|
|
20
|
+
|
|
21
|
+
const DEFAULT_TIER = "cust";
|
|
22
|
+
const DEFAULT_NAMESPACE = "bds";
|
|
23
|
+
const DEFAULT_KUBECONFIG_PATH = "$HOME/.kube/spc-config";
|
|
24
|
+
const DEFAULT_SPC_BINARY = "/usr/local/bin/spc";
|
|
25
|
+
const DEFAULT_KUBECTL_BINARY = "/usr/local/bin/kubectl";
|
|
26
|
+
const DEFAULT_AWS_BINARY = "/usr/local/bin/aws";
|
|
27
|
+
const DEFAULT_SSO_CLI_BINARY = "/Users/boqiang.liang/bin/sso-cli";
|
|
28
|
+
const DEFAULT_AWS_PROFILE = "sso-cust";
|
|
29
|
+
const DEFAULT_AWS_REGION = "";
|
|
30
|
+
const DEFAULT_EXPECT_BINARY = "/usr/bin/expect";
|
|
31
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
32
|
+
const DEFAULT_READY_WAIT_MS = 20_000;
|
|
33
|
+
const VALID_TIERS = ["dev", "eng", "cust", "fedstg", "fed"];
|
|
34
|
+
|
|
35
|
+
function normalizeText(value) {
|
|
36
|
+
return typeof value === "string" ? value.trim() : "";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function expandHome(value) {
|
|
40
|
+
const text = normalizeText(value);
|
|
41
|
+
if (text === "$HOME") return process.env.HOME || text;
|
|
42
|
+
if (text.startsWith("$HOME/")) return join(process.env.HOME || "$HOME", text.slice("$HOME/".length));
|
|
43
|
+
if (text === "~") return process.env.HOME || text;
|
|
44
|
+
if (text.startsWith("~/")) return join(process.env.HOME || "~", text.slice(2));
|
|
45
|
+
return text;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeInput(input = {}) {
|
|
49
|
+
const tier = normalizeText(input.tier || input.environment) || DEFAULT_TIER;
|
|
50
|
+
if (!VALID_TIERS.includes(tier)) {
|
|
51
|
+
throw new Error(`tier must be one of ${VALID_TIERS.join(", ")}.`);
|
|
52
|
+
}
|
|
53
|
+
const developerAccess = typeof input.developerAccess === "boolean"
|
|
54
|
+
? input.developerAccess
|
|
55
|
+
: ["dev", "eng"].includes(tier);
|
|
56
|
+
return {
|
|
57
|
+
action: normalizeText(input.action) || "plan",
|
|
58
|
+
cluster: normalizeText(input.cluster),
|
|
59
|
+
tier,
|
|
60
|
+
developerAccess,
|
|
61
|
+
namespace: normalizeText(input.namespace) || DEFAULT_NAMESPACE,
|
|
62
|
+
kubeconfigPath: normalizeText(input.kubeconfigPath) || DEFAULT_KUBECONFIG_PATH,
|
|
63
|
+
spcBinary: normalizeText(input.spcBinary) || DEFAULT_SPC_BINARY,
|
|
64
|
+
kubectlBinary: normalizeText(input.kubectlBinary) || DEFAULT_KUBECTL_BINARY,
|
|
65
|
+
awsBinary: normalizeText(input.awsBinary) || DEFAULT_AWS_BINARY,
|
|
66
|
+
ssoCliBinary: normalizeText(input.ssoCliBinary) || DEFAULT_SSO_CLI_BINARY,
|
|
67
|
+
awsProfile: normalizeText(input.awsProfile) || DEFAULT_AWS_PROFILE,
|
|
68
|
+
awsRegion: normalizeText(input.awsRegion) || DEFAULT_AWS_REGION,
|
|
69
|
+
expectBinary: normalizeText(input.expectBinary) || DEFAULT_EXPECT_BINARY,
|
|
70
|
+
keychainService: DEFAULT_WORKDAY_LDAP_KEYCHAIN_SERVICE,
|
|
71
|
+
keychainAccount: DEFAULT_WORKDAY_LDAP_PROD_KEYCHAIN_ACCOUNT || DEFAULT_WORKDAY_LDAP_KEYCHAIN_ACCOUNT,
|
|
72
|
+
humanApprovedLocalWrite: input.humanApprovedLocalWrite === true,
|
|
73
|
+
hitlApproval: input.hitlApproval,
|
|
74
|
+
timeoutMs: Number.isInteger(input.timeoutMs) ? Math.max(1000, input.timeoutMs) : DEFAULT_TIMEOUT_MS,
|
|
75
|
+
readyWaitMs: Number.isInteger(input.readyWaitMs) ? Math.max(0, input.readyWaitMs) : DEFAULT_READY_WAIT_MS,
|
|
76
|
+
logPath: normalizeText(input.logPath)
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function assertCluster(input) {
|
|
81
|
+
if (!input.cluster) {
|
|
82
|
+
throw new Error("cluster is required, for example s0026.");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function assertStructuredHitlApproval(input, context) {
|
|
87
|
+
const approval = input.hitlApproval;
|
|
88
|
+
if (!approval || typeof approval !== "object") {
|
|
89
|
+
throw new Error(`${context} requires structured HITL approval with approved, approvedBy, action, target, reason, expectedImpact, and risk.`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const missing = ["approvedBy", "action", "target", "reason", "expectedImpact", "risk"]
|
|
93
|
+
.filter((key) => !normalizeText(approval[key]));
|
|
94
|
+
if (approval.approved !== true || missing.length > 0) {
|
|
95
|
+
throw new Error(`${context} requires approved=true and complete HITL details: ${missing.join(", ") || "approved"}.`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function spcArgs(input) {
|
|
100
|
+
return [
|
|
101
|
+
...(input.developerAccess ? ["-d"] : []),
|
|
102
|
+
"connect",
|
|
103
|
+
input.tier,
|
|
104
|
+
input.cluster,
|
|
105
|
+
"--kubeconfig-path",
|
|
106
|
+
expandHome(input.kubeconfigPath)
|
|
107
|
+
];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function kubectlArgs(input, args) {
|
|
111
|
+
return ["--kubeconfig", expandHome(input.kubeconfigPath), ...args];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function commandString(command, args) {
|
|
115
|
+
return [command, ...args].map(shellQuote).join(" ");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function awsEnv(input) {
|
|
119
|
+
const env = {
|
|
120
|
+
...process.env,
|
|
121
|
+
AWS_PROFILE: input.awsProfile
|
|
122
|
+
};
|
|
123
|
+
if (input.awsRegion) {
|
|
124
|
+
env.AWS_REGION = input.awsRegion;
|
|
125
|
+
env.AWS_DEFAULT_REGION = input.awsRegion;
|
|
126
|
+
} else {
|
|
127
|
+
delete env.AWS_REGION;
|
|
128
|
+
delete env.AWS_DEFAULT_REGION;
|
|
129
|
+
}
|
|
130
|
+
return env;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function spcEnv(input) {
|
|
134
|
+
const env = {
|
|
135
|
+
...process.env,
|
|
136
|
+
AWS_PROFILE: input.awsProfile,
|
|
137
|
+
PATH: [
|
|
138
|
+
"/Users/boqiang.liang/bin",
|
|
139
|
+
"/usr/local/bin",
|
|
140
|
+
"/opt/homebrew/bin",
|
|
141
|
+
process.env.PATH || ""
|
|
142
|
+
].filter(Boolean).join(":")
|
|
143
|
+
};
|
|
144
|
+
delete env.AWS_REGION;
|
|
145
|
+
delete env.AWS_DEFAULT_REGION;
|
|
146
|
+
return env;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function defaultLogPath(input) {
|
|
150
|
+
return join(tmpdir(), `workday-public-cloud-spc-${input.tier}-${input.cluster}.log`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function awsRefreshArgs(input) {
|
|
154
|
+
return [
|
|
155
|
+
"aws",
|
|
156
|
+
"--force",
|
|
157
|
+
"--profile",
|
|
158
|
+
input.awsProfile,
|
|
159
|
+
...(input.awsRegion ? ["--region", input.awsRegion] : [])
|
|
160
|
+
];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function buildPlan(input) {
|
|
164
|
+
const normalized = normalizeInput(input);
|
|
165
|
+
assertCluster(normalized);
|
|
166
|
+
const logPath = normalized.logPath || defaultLogPath(normalized);
|
|
167
|
+
return {
|
|
168
|
+
mode: "public-cloud-spc-local-auth-helper",
|
|
169
|
+
action: normalized.action,
|
|
170
|
+
target: {
|
|
171
|
+
accessPlane: "public-cloud",
|
|
172
|
+
loginMethod: "spc",
|
|
173
|
+
tier: normalized.tier,
|
|
174
|
+
cluster: normalized.cluster,
|
|
175
|
+
namespace: normalized.namespace,
|
|
176
|
+
developerAccess: normalized.developerAccess,
|
|
177
|
+
kubeconfigPath: normalized.kubeconfigPath
|
|
178
|
+
},
|
|
179
|
+
credentialSource: {
|
|
180
|
+
keychainService: normalized.keychainService,
|
|
181
|
+
keychainAccount: normalized.keychainAccount,
|
|
182
|
+
secretHandling: "read LDAP token from macOS Keychain, pass it only to expect via environment, never return it"
|
|
183
|
+
,
|
|
184
|
+
credentialSelection: "Public cloud SPC login uses LDAP PROD for all tiers; private cloud Boundary login also uses LDAP PROD"
|
|
185
|
+
},
|
|
186
|
+
commands: {
|
|
187
|
+
awsRefresh: commandString(normalized.ssoCliBinary, awsRefreshArgs(normalized)),
|
|
188
|
+
awsStatus: `AWS_PROFILE=${shellQuote(normalized.awsProfile)} ${commandString(normalized.awsBinary, ["sts", "get-caller-identity"])}`,
|
|
189
|
+
spcConnect: commandString(normalized.spcBinary, spcArgs(normalized)),
|
|
190
|
+
status: commandString(normalized.kubectlBinary, kubectlArgs(normalized, ["config", "current-context"])),
|
|
191
|
+
whoami: commandString(normalized.kubectlBinary, kubectlArgs(normalized, ["auth", "whoami"])),
|
|
192
|
+
namespaceReadCheck: commandString(normalized.kubectlBinary, kubectlArgs(normalized, ["auth", "can-i", "get", "pods", "-n", normalized.namespace]))
|
|
193
|
+
},
|
|
194
|
+
localStateChanges: [
|
|
195
|
+
"spc connect may update the local SPC kubeconfig/context",
|
|
196
|
+
"sso-cli aws --force may refresh local AWS STS credentials for the sso-cust profile",
|
|
197
|
+
"spc connect starts a local forwarding session that must stay running for Kubernetes reads"
|
|
198
|
+
],
|
|
199
|
+
requiredForLogin: {
|
|
200
|
+
prerequisite: "Workday Security Network login/session must already be active before starting SPC.",
|
|
201
|
+
humanApprovedLocalWrite: true,
|
|
202
|
+
structuredHitlApproval: true,
|
|
203
|
+
awsCredentialRefresh: `Run sso-cli aws --force --profile ${normalized.awsProfile} before SPC when AWS STS is expired.`,
|
|
204
|
+
keychainEntry: `${normalized.keychainService}/${normalized.keychainAccount}`,
|
|
205
|
+
fallbackKeychainEntry: "wallee/wallee.workday_prod_ldap"
|
|
206
|
+
},
|
|
207
|
+
logPath,
|
|
208
|
+
forbiddenActions: [
|
|
209
|
+
"kubectl apply",
|
|
210
|
+
"kubectl patch",
|
|
211
|
+
"kubectl scale",
|
|
212
|
+
"kubectl delete",
|
|
213
|
+
"kubectl rollout restart",
|
|
214
|
+
"Argo Sync",
|
|
215
|
+
"Argo Rollback"
|
|
216
|
+
],
|
|
217
|
+
remoteWrite: "not_executed"
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function readPasswordFromKeychain(execFileImpl) {
|
|
222
|
+
for (const candidate of workdayProdCredentialCandidates()) {
|
|
223
|
+
try {
|
|
224
|
+
const result = await execFileImpl("/usr/bin/security", [
|
|
225
|
+
"find-generic-password",
|
|
226
|
+
"-s",
|
|
227
|
+
candidate.service,
|
|
228
|
+
"-a",
|
|
229
|
+
candidate.account,
|
|
230
|
+
"-w"
|
|
231
|
+
], {
|
|
232
|
+
maxBuffer: 1024 * 1024,
|
|
233
|
+
timeout: 10_000
|
|
234
|
+
});
|
|
235
|
+
const password = extractTokenFromKeychainSecret(result?.stdout);
|
|
236
|
+
if (password) return { password, keychainService: candidate.service, keychainAccount: candidate.account };
|
|
237
|
+
} catch (_error) {
|
|
238
|
+
// Try the legacy Workday PROD credential source next.
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return { password: "", keychainService: DEFAULT_WORKDAY_LDAP_KEYCHAIN_SERVICE, keychainAccount: DEFAULT_WORKDAY_LDAP_PROD_KEYCHAIN_ACCOUNT };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function tclListValue(value) {
|
|
245
|
+
return `{${String(value).replaceAll("\\", "\\\\").replaceAll("{", "\\{").replaceAll("}", "\\}")}}`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function buildExpectScript(input) {
|
|
249
|
+
const commandList = [input.spcBinary, ...spcArgs(input)].map(tclListValue).join(" ");
|
|
250
|
+
return [
|
|
251
|
+
"set timeout $env(WORKDAY_SPC_LOGIN_TIMEOUT_SECONDS)",
|
|
252
|
+
"set password $env(WORKDAY_SPC_LDAP_PASSWORD)",
|
|
253
|
+
`set command [list ${commandList}]`,
|
|
254
|
+
"spawn -noecho {*}$command",
|
|
255
|
+
"expect {",
|
|
256
|
+
" -re {(?i)(password|passcode).*:} { send -- \"$password\\r\"; exp_continue }",
|
|
257
|
+
" -re {(?i)(connected|forwarding|listening|kubeconfig|context)} { puts \"WORKDAY_PUBLIC_CLOUD_SPC_LOGIN_PROGRESS\"; exp_continue }",
|
|
258
|
+
" eof { catch wait result; exit [lindex $result 3] }",
|
|
259
|
+
" timeout { puts \"WORKDAY_PUBLIC_CLOUD_SPC_LOGIN_TIMEOUT\"; exit 124 }",
|
|
260
|
+
"}"
|
|
261
|
+
].join("\n");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function redactSecret(text, password) {
|
|
265
|
+
const raw = String(text || "");
|
|
266
|
+
if (!password) return raw;
|
|
267
|
+
return raw.split(password).join("[redacted]");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function startSpcLoginProcess(spawnImpl, input, password, logPath) {
|
|
271
|
+
const fd = openSync(logPath, "a", 0o600);
|
|
272
|
+
const child = spawnImpl(input.expectBinary, ["-c", buildExpectScript(input)], {
|
|
273
|
+
detached: true,
|
|
274
|
+
stdio: ["ignore", fd, fd],
|
|
275
|
+
env: {
|
|
276
|
+
...spcEnv(input),
|
|
277
|
+
WORKDAY_SPC_LDAP_PASSWORD: password,
|
|
278
|
+
WORKDAY_SPC_LOGIN_TIMEOUT_SECONDS: String(Math.max(1, Math.ceil(input.timeoutMs / 1000)))
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
if (typeof child.unref === "function") child.unref();
|
|
282
|
+
return {
|
|
283
|
+
pid: child.pid,
|
|
284
|
+
logPath
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function runAwsStatus(execFileImpl, input) {
|
|
289
|
+
try {
|
|
290
|
+
const result = await execFileImpl(input.awsBinary, ["sts", "get-caller-identity"], {
|
|
291
|
+
env: awsEnv(input),
|
|
292
|
+
maxBuffer: 1024 * 1024,
|
|
293
|
+
timeout: 15_000
|
|
294
|
+
});
|
|
295
|
+
return {
|
|
296
|
+
ok: true,
|
|
297
|
+
profile: input.awsProfile,
|
|
298
|
+
region: input.awsRegion,
|
|
299
|
+
stdout: normalizeText(result.stdout),
|
|
300
|
+
stderr: normalizeText(result.stderr)
|
|
301
|
+
};
|
|
302
|
+
} catch (error) {
|
|
303
|
+
return {
|
|
304
|
+
ok: false,
|
|
305
|
+
profile: input.awsProfile,
|
|
306
|
+
region: input.awsRegion,
|
|
307
|
+
stdout: normalizeText(error?.stdout),
|
|
308
|
+
stderr: normalizeText(error?.stderr || error?.message)
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function refreshAwsCredentials(execFileImpl, input) {
|
|
314
|
+
const result = await execFileImpl(input.ssoCliBinary, awsRefreshArgs(input), {
|
|
315
|
+
env: awsEnv(input),
|
|
316
|
+
maxBuffer: 1024 * 1024,
|
|
317
|
+
timeout: input.timeoutMs
|
|
318
|
+
});
|
|
319
|
+
return {
|
|
320
|
+
ok: true,
|
|
321
|
+
profile: input.awsProfile,
|
|
322
|
+
region: input.awsRegion,
|
|
323
|
+
stdout: normalizeText(result.stdout),
|
|
324
|
+
stderr: normalizeText(result.stderr)
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function runKubectlStatus(execFileImpl, input) {
|
|
329
|
+
const checks = [
|
|
330
|
+
["currentContext", ["config", "current-context"]],
|
|
331
|
+
["whoami", ["auth", "whoami"]],
|
|
332
|
+
["namespaceReadCheck", ["auth", "can-i", "get", "pods", "-n", input.namespace]]
|
|
333
|
+
];
|
|
334
|
+
const results = {};
|
|
335
|
+
for (const [key, args] of checks) {
|
|
336
|
+
try {
|
|
337
|
+
const result = await execFileImpl(input.kubectlBinary, kubectlArgs(input, args), {
|
|
338
|
+
env: spcEnv(input),
|
|
339
|
+
maxBuffer: 1024 * 1024,
|
|
340
|
+
timeout: 10_000
|
|
341
|
+
});
|
|
342
|
+
results[key] = {
|
|
343
|
+
ok: true,
|
|
344
|
+
stdout: normalizeText(result.stdout),
|
|
345
|
+
stderr: normalizeText(result.stderr)
|
|
346
|
+
};
|
|
347
|
+
} catch (error) {
|
|
348
|
+
results[key] = {
|
|
349
|
+
ok: false,
|
|
350
|
+
stdout: normalizeText(error?.stdout),
|
|
351
|
+
stderr: normalizeText(error?.stderr || error?.message)
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return {
|
|
356
|
+
ready: Boolean(results.currentContext?.ok && results.whoami?.ok),
|
|
357
|
+
...results
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function waitForReady(execFileImpl, input) {
|
|
362
|
+
const deadline = Date.now() + input.readyWaitMs;
|
|
363
|
+
let latest = await runKubectlStatus(execFileImpl, input);
|
|
364
|
+
while (!latest.ready && Date.now() < deadline) {
|
|
365
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
366
|
+
latest = await runKubectlStatus(execFileImpl, input);
|
|
367
|
+
}
|
|
368
|
+
return latest;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function publicCloudSpcLogin(input, options = {}) {
|
|
372
|
+
const normalized = normalizeInput(input);
|
|
373
|
+
assertCluster(normalized);
|
|
374
|
+
const execFileImpl = options.execFileImpl || execFileAsync;
|
|
375
|
+
const spawnImpl = options.spawnImpl || spawn;
|
|
376
|
+
|
|
377
|
+
if (normalized.action === "plan") {
|
|
378
|
+
return JSON.stringify(buildPlan(normalized), null, 2);
|
|
379
|
+
}
|
|
380
|
+
if (normalized.action === "status") {
|
|
381
|
+
return JSON.stringify({
|
|
382
|
+
action: "status",
|
|
383
|
+
target: buildPlan(normalized).target,
|
|
384
|
+
awsStatus: await runAwsStatus(execFileImpl, normalized),
|
|
385
|
+
status: await runKubectlStatus(execFileImpl, normalized),
|
|
386
|
+
remoteWrite: "not_executed"
|
|
387
|
+
}, null, 2);
|
|
388
|
+
}
|
|
389
|
+
if (normalized.action !== "login") {
|
|
390
|
+
throw new Error(`Unsupported workday_public_cloud_spc_login action: ${normalized.action}`);
|
|
391
|
+
}
|
|
392
|
+
if (!normalized.humanApprovedLocalWrite) {
|
|
393
|
+
throw new Error("workday_public_cloud_spc_login action=login may update local SPC kubeconfig/session state and requires humanApprovedLocalWrite=true.");
|
|
394
|
+
}
|
|
395
|
+
assertStructuredHitlApproval(normalized, "workday_public_cloud_spc_login action=login");
|
|
396
|
+
|
|
397
|
+
const awsRefresh = await refreshAwsCredentials(execFileImpl, normalized);
|
|
398
|
+
const keychain = await readPasswordFromKeychain(execFileImpl);
|
|
399
|
+
if (!keychain.password) {
|
|
400
|
+
throw new Error(`Missing LDAP PROD password in macOS keychain service ${normalized.keychainService}, account ${normalized.keychainAccount}. Store it in eldwin/eldwin.workday_prod_ldap first; wallee/wallee.workday_prod_ldap is supported only as a fallback source.`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const logPath = normalized.logPath || defaultLogPath(normalized);
|
|
404
|
+
const processInfo = startSpcLoginProcess(spawnImpl, normalized, keychain.password, logPath);
|
|
405
|
+
const status = await waitForReady(execFileImpl, normalized);
|
|
406
|
+
return JSON.stringify({
|
|
407
|
+
action: "login",
|
|
408
|
+
target: buildPlan(normalized).target,
|
|
409
|
+
keychainService: keychain.keychainService,
|
|
410
|
+
keychainAccount: keychain.keychainAccount,
|
|
411
|
+
awsRefresh,
|
|
412
|
+
process: processInfo,
|
|
413
|
+
status,
|
|
414
|
+
transcript: "SPC login process started in the background; inspect logPath if ready=false. Secrets are not written by this tool.",
|
|
415
|
+
remoteWrite: "not_executed"
|
|
416
|
+
}, null, 2);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export class WorkdayPublicCloudSpcLoginService {
|
|
420
|
+
constructor(options = {}) {
|
|
421
|
+
this.options = options;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async invoke(input) {
|
|
425
|
+
return publicCloudSpcLogin(input, this.options);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const defaultService = new WorkdayPublicCloudSpcLoginService();
|
|
430
|
+
|
|
431
|
+
export const __test = {
|
|
432
|
+
WorkdayPublicCloudSpcLoginService,
|
|
433
|
+
buildExpectScript,
|
|
434
|
+
buildPlan,
|
|
435
|
+
commandString,
|
|
436
|
+
defaultLogPath,
|
|
437
|
+
expandHome,
|
|
438
|
+
normalizeInput,
|
|
439
|
+
readPasswordFromKeychain,
|
|
440
|
+
redactSecret,
|
|
441
|
+
refreshAwsCredentials,
|
|
442
|
+
runAwsStatus,
|
|
443
|
+
runKubectlStatus,
|
|
444
|
+
awsEnv,
|
|
445
|
+
awsRefreshArgs,
|
|
446
|
+
spcEnv,
|
|
447
|
+
publicCloudSpcLogin,
|
|
448
|
+
spcArgs,
|
|
449
|
+
tclListValue
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
const sharedTool = defineReadOnlyTool({
|
|
453
|
+
name: "workday_public_cloud_spc_login",
|
|
454
|
+
description: "Plan, check, or start a local Workday public-cloud SPC login session for a specific tier and cluster using a saved macOS Keychain LDAP token. Use this for public cloud environments; private cloud/DC access stays on Boundary/BOS-AINAT login.",
|
|
455
|
+
meta: {
|
|
456
|
+
permissions: { rule: "ask" },
|
|
457
|
+
secret_provider: "keychain"
|
|
458
|
+
},
|
|
459
|
+
inputSchema: {
|
|
460
|
+
action: z.enum(["plan", "status", "login"]).default("plan"),
|
|
461
|
+
cluster: z.string().trim().min(1).max(253),
|
|
462
|
+
tier: z.enum(VALID_TIERS).default(DEFAULT_TIER),
|
|
463
|
+
environment: z.enum(VALID_TIERS).optional().describe("Alias for tier."),
|
|
464
|
+
developerAccess: z.boolean().optional(),
|
|
465
|
+
namespace: z.string().trim().min(1).max(253).default(DEFAULT_NAMESPACE),
|
|
466
|
+
kubeconfigPath: z.string().trim().min(1).max(2048).default(DEFAULT_KUBECONFIG_PATH),
|
|
467
|
+
spcBinary: z.string().trim().min(1).max(2048).default(DEFAULT_SPC_BINARY),
|
|
468
|
+
kubectlBinary: z.string().trim().min(1).max(2048).default(DEFAULT_KUBECTL_BINARY),
|
|
469
|
+
awsBinary: z.string().trim().min(1).max(2048).default(DEFAULT_AWS_BINARY),
|
|
470
|
+
ssoCliBinary: z.string().trim().min(1).max(2048).default(DEFAULT_SSO_CLI_BINARY),
|
|
471
|
+
awsProfile: z.string().trim().min(1).max(128).default(DEFAULT_AWS_PROFILE),
|
|
472
|
+
awsRegion: z.string().trim().max(64).default(DEFAULT_AWS_REGION).describe("Optional. Leave blank to use sso-cli/aws defaults; do not pass a region into SPC or kubectl."),
|
|
473
|
+
expectBinary: z.literal(DEFAULT_EXPECT_BINARY).default(DEFAULT_EXPECT_BINARY),
|
|
474
|
+
humanApprovedLocalWrite: z.boolean().default(false).describe("Required for action=login because spc connect may update local kubeconfig/session state."),
|
|
475
|
+
hitlApproval: z.object({
|
|
476
|
+
approved: z.literal(true),
|
|
477
|
+
approvedBy: z.string().trim().min(1).max(256),
|
|
478
|
+
action: z.string().trim().min(1).max(256),
|
|
479
|
+
target: z.string().trim().min(1).max(4096),
|
|
480
|
+
reason: z.string().trim().min(1).max(2000),
|
|
481
|
+
expectedImpact: z.string().trim().min(1).max(2000),
|
|
482
|
+
risk: z.string().trim().min(1).max(2000)
|
|
483
|
+
}).optional().describe("Required for action=login because local auth state may be updated."),
|
|
484
|
+
timeoutMs: z.number().int().positive().max(600_000).default(DEFAULT_TIMEOUT_MS),
|
|
485
|
+
readyWaitMs: z.number().int().min(0).max(120_000).default(DEFAULT_READY_WAIT_MS),
|
|
486
|
+
logPath: z.string().trim().min(1).max(2048).optional()
|
|
487
|
+
},
|
|
488
|
+
async handler(input) {
|
|
489
|
+
try {
|
|
490
|
+
return ok(await defaultService.invoke(input));
|
|
491
|
+
} catch (error) {
|
|
492
|
+
return fail(error);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
export default {
|
|
498
|
+
name: sharedTool.name,
|
|
499
|
+
description,
|
|
500
|
+
inputSchema: sharedTool.inputSchema,
|
|
501
|
+
meta: sharedTool.meta,
|
|
502
|
+
annotations: sharedTool.annotations,
|
|
503
|
+
invoke: sharedTool.invoke,
|
|
504
|
+
handler: sharedTool.handler
|
|
505
|
+
};
|