ardent-cli-beta 0.0.4 → 0.0.5
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/README.md +12 -1
- package/dist/cloud/source-credentials.d.ts +18 -0
- package/dist/cloud/source-credentials.js +143 -0
- package/dist/commands/connectors/credentials/set.d.ts +20 -0
- package/dist/commands/connectors/credentials/set.js +59 -0
- package/oclif.manifest.json +87 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,7 +9,18 @@ The Oclif foundation and token authentication are implemented. `ardent login
|
|
|
9
9
|
same flag non-interactively. `ardent status` and `ardent logout` inspect and
|
|
10
10
|
remove that local session. The CLI always talks to Ardent's production control
|
|
11
11
|
plane. Oclif provides help, version, autocomplete, and native JSON command
|
|
12
|
-
behavior.
|
|
12
|
+
behavior. Connector credential delivery is the first resource command; general
|
|
13
|
+
resource commands and resource context remain owed.
|
|
14
|
+
|
|
15
|
+
`ardent connectors credentials set` delivers a source database password and a
|
|
16
|
+
verified CA directly to the selected environment's authoritative AWS account and
|
|
17
|
+
region. The caller must be logged in to Ardent and authenticated to that AWS account. Supply
|
|
18
|
+
the password only as `{"password":"..."}` on standard input and the CA with
|
|
19
|
+
`--ca-file`; `--request` is a caller-generated UUID that must be reused for a
|
|
20
|
+
retry. The command checks connector ownership through the control-plane API,
|
|
21
|
+
checks the local AWS account through STS, prints its target, then creates the CA
|
|
22
|
+
and password secrets in that order. It never sends either value to the control
|
|
23
|
+
plane or places one on the AWS command's argument vector.
|
|
13
24
|
|
|
14
25
|
Set `ARDENT_API_URL` to point the CLI at a development control plane. Without
|
|
15
26
|
it, the CLI uses `https://production.tryardent.com`.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface Target {
|
|
2
|
+
account: string;
|
|
3
|
+
region: string;
|
|
4
|
+
environment: string;
|
|
5
|
+
connector: string;
|
|
6
|
+
request: string;
|
|
7
|
+
}
|
|
8
|
+
type Run = (arguments_: string[], input?: string) => Promise<string>;
|
|
9
|
+
export declare function validateCA(pem: string, now?: Date): string;
|
|
10
|
+
export declare function parsePasswordDocument(raw: string): string;
|
|
11
|
+
export declare function deliver(target: Target, ca: string, password: string, run?: Run): Promise<void>;
|
|
12
|
+
export declare class AWSFailure extends Error {
|
|
13
|
+
readonly notFound: boolean;
|
|
14
|
+
constructor(notFound?: boolean);
|
|
15
|
+
}
|
|
16
|
+
export declare function account(run?: Run): Promise<string>;
|
|
17
|
+
export declare function runAWS(arguments_: string[], input?: string, executable?: string, timeoutMilliseconds?: number): Promise<string>;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { timingSafeEqual, X509Certificate } from 'node:crypto';
|
|
3
|
+
const secretName = (target, suffix) => `ardent/${target.environment}/connectors/${target.connector}/${suffix}`;
|
|
4
|
+
export function validateCA(pem, now = new Date()) {
|
|
5
|
+
if (Buffer.byteLength(pem) > 64 * 1024 || !pem.endsWith('\n'))
|
|
6
|
+
throw new Error('The source CA must be a newline-terminated PEM certificate under 64 KiB.');
|
|
7
|
+
if ((pem.match(/-----BEGIN CERTIFICATE-----/g) ?? []).length !== 1 || /PRIVATE KEY/.test(pem))
|
|
8
|
+
throw new Error('The source CA must contain exactly one certificate and no private key.');
|
|
9
|
+
let certificate;
|
|
10
|
+
try {
|
|
11
|
+
certificate = new X509Certificate(pem);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
throw new Error('The source CA is not a valid PEM certificate.');
|
|
15
|
+
}
|
|
16
|
+
if (!certificate.ca || now < new Date(certificate.validFrom) || now >= new Date(certificate.validTo))
|
|
17
|
+
throw new Error('The source CA must be a currently valid certificate authority.');
|
|
18
|
+
return pem;
|
|
19
|
+
}
|
|
20
|
+
export function parsePasswordDocument(raw) {
|
|
21
|
+
if (Buffer.byteLength(raw) > 64 * 1024)
|
|
22
|
+
throw new Error('The source credential document is too large.');
|
|
23
|
+
let parsed;
|
|
24
|
+
try {
|
|
25
|
+
parsed = JSON.parse(raw);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
throw new Error('Standard input must be one JSON object containing password.');
|
|
29
|
+
}
|
|
30
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed) || Object.keys(parsed).length !== 1 || typeof parsed.password !== 'string' || parsed.password.length === 0 || parsed.password.includes('\0')) {
|
|
31
|
+
throw new Error('Standard input must be one JSON object containing a non-empty password and no other fields.');
|
|
32
|
+
}
|
|
33
|
+
return parsed.password;
|
|
34
|
+
}
|
|
35
|
+
export async function deliver(target, ca, password, run = runAWS) {
|
|
36
|
+
for (const [name, value] of [
|
|
37
|
+
[secretName(target, 'source-database-ca'), ca],
|
|
38
|
+
[secretName(target, 'source-database-password'), password],
|
|
39
|
+
]) {
|
|
40
|
+
const description = await describe(run, target, name);
|
|
41
|
+
if (description !== undefined) {
|
|
42
|
+
const tags = new Map((description.Tags ?? []).map((tag) => [tag.Key, tag.Value]));
|
|
43
|
+
if (tags.get('ardent:credential-request') !== target.request || tags.get('ardent:environment') !== target.environment || tags.get('ardent:connector') !== target.connector) {
|
|
44
|
+
throw new Error(`The deterministic ${name.endsWith('-ca') ? 'CA' : 'password'} secret already belongs to another request.`);
|
|
45
|
+
}
|
|
46
|
+
const encodedExisting = await run(['secretsmanager', 'get-secret-value', '--region', target.region, '--secret-id', name, '--query', 'SecretString', '--output', 'json']);
|
|
47
|
+
let existing;
|
|
48
|
+
try {
|
|
49
|
+
existing = JSON.parse(encodedExisting);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
throw new AWSFailure();
|
|
53
|
+
}
|
|
54
|
+
if (typeof existing !== 'string')
|
|
55
|
+
throw new AWSFailure();
|
|
56
|
+
const existingBytes = Buffer.from(existing);
|
|
57
|
+
const requestedBytes = Buffer.from(value);
|
|
58
|
+
if (existingBytes.length !== requestedBytes.length || !timingSafeEqual(existingBytes, requestedBytes)) {
|
|
59
|
+
throw new Error(`The deterministic ${name.endsWith('-ca') ? 'CA' : 'password'} secret already has different contents for this request.`);
|
|
60
|
+
}
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
await run([
|
|
64
|
+
'secretsmanager', 'create-secret', '--region', target.region, '--name', name,
|
|
65
|
+
'--secret-string', 'file:///dev/stdin', '--tags',
|
|
66
|
+
`Key=ardent:credential-request,Value=${target.request}`,
|
|
67
|
+
`Key=ardent:environment,Value=${target.environment}`,
|
|
68
|
+
`Key=ardent:connector,Value=${target.connector}`,
|
|
69
|
+
'--output', 'json',
|
|
70
|
+
], value);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async function describe(run, target, name) {
|
|
74
|
+
try {
|
|
75
|
+
return JSON.parse(await run(['secretsmanager', 'describe-secret', '--region', target.region, '--secret-id', name, '--output', 'json']));
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (error instanceof AWSFailure && error.notFound)
|
|
79
|
+
return undefined;
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export class AWSFailure extends Error {
|
|
84
|
+
notFound;
|
|
85
|
+
constructor(notFound = false) {
|
|
86
|
+
super(notFound ? 'The AWS secret does not exist.' : 'The AWS credential operation failed.');
|
|
87
|
+
this.notFound = notFound;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
export async function account(run = runAWS) {
|
|
91
|
+
const value = (await run(['sts', 'get-caller-identity', '--query', 'Account', '--output', 'text'])).trim();
|
|
92
|
+
if (!/^\d{12}$/.test(value))
|
|
93
|
+
throw new Error('AWS returned an invalid account identity.');
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
export function runAWS(arguments_, input, executable = 'aws', timeoutMilliseconds = 30_000) {
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
const child = spawn(executable, arguments_, { shell: false, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
99
|
+
let settled = false;
|
|
100
|
+
let stdout = '';
|
|
101
|
+
let stderr = '';
|
|
102
|
+
let timeout;
|
|
103
|
+
const finish = (error) => {
|
|
104
|
+
if (settled)
|
|
105
|
+
return;
|
|
106
|
+
settled = true;
|
|
107
|
+
if (timeout)
|
|
108
|
+
clearTimeout(timeout);
|
|
109
|
+
if (error)
|
|
110
|
+
reject(error);
|
|
111
|
+
else
|
|
112
|
+
resolve(stdout);
|
|
113
|
+
};
|
|
114
|
+
timeout = setTimeout(() => {
|
|
115
|
+
child.kill('SIGKILL');
|
|
116
|
+
finish(new AWSFailure());
|
|
117
|
+
}, timeoutMilliseconds);
|
|
118
|
+
const failInput = () => {
|
|
119
|
+
child.kill('SIGKILL');
|
|
120
|
+
finish(new AWSFailure());
|
|
121
|
+
};
|
|
122
|
+
child.stdout.setEncoding('utf8').on('data', (chunk) => {
|
|
123
|
+
if (stdout.length <= 128 * 1024)
|
|
124
|
+
stdout += chunk;
|
|
125
|
+
});
|
|
126
|
+
child.stderr.setEncoding('utf8').on('data', (chunk) => {
|
|
127
|
+
if (stderr.length <= 16 * 1024)
|
|
128
|
+
stderr += chunk;
|
|
129
|
+
});
|
|
130
|
+
child.on('error', () => finish(new AWSFailure()));
|
|
131
|
+
child.stdin.on('error', failInput);
|
|
132
|
+
child.on('close', (status) => {
|
|
133
|
+
if (status === 0 && Buffer.byteLength(stdout) <= 128 * 1024)
|
|
134
|
+
finish();
|
|
135
|
+
else
|
|
136
|
+
finish(new AWSFailure(/ResourceNotFoundException/.test(stderr)));
|
|
137
|
+
});
|
|
138
|
+
child.stdin.end(input, (error) => {
|
|
139
|
+
if (error)
|
|
140
|
+
failInput();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { ArdentCommand } from '../../../command.js';
|
|
2
|
+
export default class SetSourceCredentials extends ArdentCommand {
|
|
3
|
+
static description: string;
|
|
4
|
+
static helpGroup: string;
|
|
5
|
+
static flags: {
|
|
6
|
+
organization: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
|
+
project: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
|
+
environment: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
|
+
connector: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
request: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
+
'ca-file': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
|
+
'password-stdin': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
13
|
+
};
|
|
14
|
+
run(): Promise<{
|
|
15
|
+
connector: string;
|
|
16
|
+
environment: string;
|
|
17
|
+
account: string;
|
|
18
|
+
delivered: true;
|
|
19
|
+
}>;
|
|
20
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { request } from '../../../api/client.js';
|
|
4
|
+
import { loadSession } from '../../../auth/credentials.js';
|
|
5
|
+
import { account, deliver, parsePasswordDocument, validateCA } from '../../../cloud/source-credentials.js';
|
|
6
|
+
import { ArdentCommand } from '../../../command.js';
|
|
7
|
+
export default class SetSourceCredentials extends ArdentCommand {
|
|
8
|
+
static description = 'Deliver a connector password and source CA directly to its environment AWS account';
|
|
9
|
+
static helpGroup = 'Connectors';
|
|
10
|
+
static flags = {
|
|
11
|
+
organization: Flags.string({ required: true, description: 'Owning Ardent organization ID' }),
|
|
12
|
+
project: Flags.string({ required: true, description: 'Owning Ardent project ID' }),
|
|
13
|
+
environment: Flags.string({ required: true, description: 'Target Ardent environment ID' }),
|
|
14
|
+
connector: Flags.string({ required: true, description: 'Target Ardent connector ID' }),
|
|
15
|
+
request: Flags.string({ required: true, description: 'Stable UUID for safe retries' }),
|
|
16
|
+
'ca-file': Flags.string({ required: true, description: 'PEM source certificate-authority file' }),
|
|
17
|
+
'password-stdin': Flags.boolean({ required: true, description: 'Read {"password":"…"} from standard input' }),
|
|
18
|
+
};
|
|
19
|
+
async run() {
|
|
20
|
+
const { flags } = await this.parse(SetSourceCredentials);
|
|
21
|
+
const uuid = '[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}';
|
|
22
|
+
if (!new RegExp(`^environment_${uuid}$`).test(flags.environment) || !new RegExp(`^connector_${uuid}$`).test(flags.connector) || !new RegExp(`^${uuid}$`).test(flags.request))
|
|
23
|
+
this.error('Environment, connector, or request identity is invalid.');
|
|
24
|
+
const session = await loadSession(this.config.configDir);
|
|
25
|
+
if (!session)
|
|
26
|
+
this.error('Log in before delivering source credentials.');
|
|
27
|
+
const connectors = await request(session.token, `/connectors?organization_id=${encodeURIComponent(flags.organization)}&project_id=${encodeURIComponent(flags.project)}`);
|
|
28
|
+
if (!connectors.some((connector) => connector.id === flags.connector && connector.environment_id === flags.environment))
|
|
29
|
+
this.error('The selected connector does not belong to the selected project and environment.');
|
|
30
|
+
const environments = await request(session.token, `/connector-environments?organization_id=${encodeURIComponent(flags.organization)}&project_id=${encodeURIComponent(flags.project)}`);
|
|
31
|
+
const environment = environments.find((candidate) => candidate.id === flags.environment);
|
|
32
|
+
if (!environment || !/^\d{12}$/.test(environment.cloud_account_id) || !/^[a-z]{2}(?:-gov)?-[a-z]+-\d$/.test(environment.cloud_region))
|
|
33
|
+
this.error('The selected environment has no authoritative AWS placement.');
|
|
34
|
+
const actualAccount = await account();
|
|
35
|
+
if (actualAccount !== environment.cloud_account_id)
|
|
36
|
+
this.error(`AWS is authenticated to account ${actualAccount}, not the environment account ${environment.cloud_account_id}.`);
|
|
37
|
+
const ca = validateCA(await readFile(flags['ca-file'], 'utf8'));
|
|
38
|
+
const input = await readStdin();
|
|
39
|
+
const password = parsePasswordDocument(input);
|
|
40
|
+
if (!this.jsonEnabled())
|
|
41
|
+
this.log(`Delivering source credentials for ${flags.connector} in ${flags.environment} to AWS account ${environment.cloud_account_id} (${environment.cloud_region})`);
|
|
42
|
+
await deliver({ account: environment.cloud_account_id, region: environment.cloud_region, environment: flags.environment, connector: flags.connector, request: flags.request }, ca, password);
|
|
43
|
+
if (!this.jsonEnabled())
|
|
44
|
+
this.log('✓ Source password and CA delivered');
|
|
45
|
+
return { connector: flags.connector, environment: flags.environment, account: environment.cloud_account_id, delivered: true };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async function readStdin() {
|
|
49
|
+
const chunks = [];
|
|
50
|
+
let size = 0;
|
|
51
|
+
for await (const chunk of process.stdin) {
|
|
52
|
+
const value = Buffer.from(chunk);
|
|
53
|
+
size += value.length;
|
|
54
|
+
if (size > 64 * 1024)
|
|
55
|
+
throw new Error('The source credential document is too large.');
|
|
56
|
+
chunks.push(value);
|
|
57
|
+
}
|
|
58
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
59
|
+
}
|
package/oclif.manifest.json
CHANGED
|
@@ -94,7 +94,93 @@
|
|
|
94
94
|
"commands",
|
|
95
95
|
"status.js"
|
|
96
96
|
]
|
|
97
|
+
},
|
|
98
|
+
"connectors:credentials:set": {
|
|
99
|
+
"aliases": [],
|
|
100
|
+
"args": {},
|
|
101
|
+
"description": "Deliver a connector password and source CA directly to its environment AWS account",
|
|
102
|
+
"flags": {
|
|
103
|
+
"json": {
|
|
104
|
+
"description": "Format output as json.",
|
|
105
|
+
"helpGroup": "GLOBAL",
|
|
106
|
+
"name": "json",
|
|
107
|
+
"allowNo": false,
|
|
108
|
+
"type": "boolean"
|
|
109
|
+
},
|
|
110
|
+
"organization": {
|
|
111
|
+
"description": "Owning Ardent organization ID",
|
|
112
|
+
"name": "organization",
|
|
113
|
+
"required": true,
|
|
114
|
+
"hasDynamicHelp": false,
|
|
115
|
+
"multiple": false,
|
|
116
|
+
"type": "option"
|
|
117
|
+
},
|
|
118
|
+
"project": {
|
|
119
|
+
"description": "Owning Ardent project ID",
|
|
120
|
+
"name": "project",
|
|
121
|
+
"required": true,
|
|
122
|
+
"hasDynamicHelp": false,
|
|
123
|
+
"multiple": false,
|
|
124
|
+
"type": "option"
|
|
125
|
+
},
|
|
126
|
+
"environment": {
|
|
127
|
+
"description": "Target Ardent environment ID",
|
|
128
|
+
"name": "environment",
|
|
129
|
+
"required": true,
|
|
130
|
+
"hasDynamicHelp": false,
|
|
131
|
+
"multiple": false,
|
|
132
|
+
"type": "option"
|
|
133
|
+
},
|
|
134
|
+
"connector": {
|
|
135
|
+
"description": "Target Ardent connector ID",
|
|
136
|
+
"name": "connector",
|
|
137
|
+
"required": true,
|
|
138
|
+
"hasDynamicHelp": false,
|
|
139
|
+
"multiple": false,
|
|
140
|
+
"type": "option"
|
|
141
|
+
},
|
|
142
|
+
"request": {
|
|
143
|
+
"description": "Stable UUID for safe retries",
|
|
144
|
+
"name": "request",
|
|
145
|
+
"required": true,
|
|
146
|
+
"hasDynamicHelp": false,
|
|
147
|
+
"multiple": false,
|
|
148
|
+
"type": "option"
|
|
149
|
+
},
|
|
150
|
+
"ca-file": {
|
|
151
|
+
"description": "PEM source certificate-authority file",
|
|
152
|
+
"name": "ca-file",
|
|
153
|
+
"required": true,
|
|
154
|
+
"hasDynamicHelp": false,
|
|
155
|
+
"multiple": false,
|
|
156
|
+
"type": "option"
|
|
157
|
+
},
|
|
158
|
+
"password-stdin": {
|
|
159
|
+
"description": "Read {\"password\":\"…\"} from standard input",
|
|
160
|
+
"name": "password-stdin",
|
|
161
|
+
"required": true,
|
|
162
|
+
"allowNo": false,
|
|
163
|
+
"type": "boolean"
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
"hasDynamicHelp": false,
|
|
167
|
+
"hiddenAliases": [],
|
|
168
|
+
"id": "connectors:credentials:set",
|
|
169
|
+
"pluginAlias": "ardent-cli-beta",
|
|
170
|
+
"pluginName": "ardent-cli-beta",
|
|
171
|
+
"pluginType": "core",
|
|
172
|
+
"strict": true,
|
|
173
|
+
"enableJsonFlag": true,
|
|
174
|
+
"helpGroup": "Connectors",
|
|
175
|
+
"isESM": true,
|
|
176
|
+
"relativePath": [
|
|
177
|
+
"dist",
|
|
178
|
+
"commands",
|
|
179
|
+
"connectors",
|
|
180
|
+
"credentials",
|
|
181
|
+
"set.js"
|
|
182
|
+
]
|
|
97
183
|
}
|
|
98
184
|
},
|
|
99
|
-
"version": "0.0.
|
|
185
|
+
"version": "0.0.5"
|
|
100
186
|
}
|