@davidvornholt/standards 0.13.0 → 0.14.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/README.md +11 -2
- package/package.json +27 -1
- package/src/cli.ts +11 -0
- package/src/creds-add-cloudflare-commit.ts +46 -0
- package/src/creds-add-github.ts +66 -0
- package/src/creds-add.ts +183 -0
- package/src/creds-browser.ts +28 -0
- package/src/creds-cloudflare-account.ts +4 -0
- package/src/creds-cloudflare-api.ts +200 -0
- package/src/creds-cloudflare-condition.ts +63 -0
- package/src/creds-cloudflare-pagination.ts +49 -0
- package/src/creds-cloudflare.ts +197 -0
- package/src/creds-commands.ts +200 -0
- package/src/creds-dest.ts +183 -0
- package/src/creds-login-cloudflare.ts +155 -0
- package/src/creds-login-github-manifest.ts +104 -0
- package/src/creds-login-github.ts +133 -0
- package/src/creds-naming.ts +82 -0
- package/src/creds-plan-revoke.ts +15 -0
- package/src/creds-plan-run.ts +198 -0
- package/src/creds-plan.ts +185 -0
- package/src/creds-prompt.ts +62 -0
- package/src/creds-sops-editor.ts +170 -0
- package/src/creds-sops-structure.ts +199 -0
- package/src/creds-sops.ts +198 -0
- package/src/creds-store-lock.ts +102 -0
- package/src/creds-store.ts +199 -0
- package/src/dev-env.ts +4 -22
- package/src/poller-codex.ts +34 -6
- package/src/poller-config.ts +11 -1
- package/src/poller-fix-run.ts +5 -4
- package/src/poller-job-shared.ts +3 -1
- package/src/poller-review-execution.ts +6 -5
- package/src/poller-units.ts +7 -2
- package/src/sops-exec.ts +47 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { isRecord } from './github-settings-parse';
|
|
2
|
+
|
|
3
|
+
export type TokenCondition = {
|
|
4
|
+
readonly requestIp: {
|
|
5
|
+
readonly in?: ReadonlyArray<string>;
|
|
6
|
+
readonly notIn?: ReadonlyArray<string>;
|
|
7
|
+
};
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type DecodedTokenCondition =
|
|
11
|
+
| { readonly supported: true; readonly value: TokenCondition | null }
|
|
12
|
+
| { readonly supported: false };
|
|
13
|
+
|
|
14
|
+
const stringArray = (value: unknown): value is ReadonlyArray<string> =>
|
|
15
|
+
Array.isArray(value) && value.every((entry) => typeof entry === 'string');
|
|
16
|
+
|
|
17
|
+
export const decodeTokenCondition = (raw: unknown): DecodedTokenCondition => {
|
|
18
|
+
if (raw === undefined || raw === null) {
|
|
19
|
+
return { supported: true, value: null };
|
|
20
|
+
}
|
|
21
|
+
if (!isRecord(raw) || Object.keys(raw).some((key) => key !== 'request_ip')) {
|
|
22
|
+
return { supported: false };
|
|
23
|
+
}
|
|
24
|
+
const requestIp = raw.request_ip;
|
|
25
|
+
if (
|
|
26
|
+
!isRecord(requestIp) ||
|
|
27
|
+
Object.keys(requestIp).some((key) => key !== 'in' && key !== 'not_in') ||
|
|
28
|
+
('in' in requestIp && !stringArray(requestIp.in)) ||
|
|
29
|
+
('not_in' in requestIp && !stringArray(requestIp.not_in))
|
|
30
|
+
) {
|
|
31
|
+
return { supported: false };
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
supported: true,
|
|
35
|
+
value: {
|
|
36
|
+
requestIp: {
|
|
37
|
+
...('in' in requestIp
|
|
38
|
+
? { in: requestIp.in as ReadonlyArray<string> }
|
|
39
|
+
: {}),
|
|
40
|
+
...('not_in' in requestIp
|
|
41
|
+
? { notIn: requestIp.not_in as ReadonlyArray<string> }
|
|
42
|
+
: {}),
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const encodeTokenCondition = (
|
|
49
|
+
condition: TokenCondition,
|
|
50
|
+
): Readonly<Record<string, unknown>> => ({
|
|
51
|
+
// biome-ignore lint/style/useNamingConvention: Cloudflare's condition wire field is snake_case.
|
|
52
|
+
request_ip: {
|
|
53
|
+
...(condition.requestIp.in === undefined
|
|
54
|
+
? {}
|
|
55
|
+
: { in: condition.requestIp.in }),
|
|
56
|
+
...(condition.requestIp.notIn === undefined
|
|
57
|
+
? {}
|
|
58
|
+
: {
|
|
59
|
+
// biome-ignore lint/style/useNamingConvention: Cloudflare's condition wire field is snake_case.
|
|
60
|
+
not_in: condition.requestIp.notIn,
|
|
61
|
+
}),
|
|
62
|
+
},
|
|
63
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CfResult,
|
|
3
|
+
type CloudflareToken,
|
|
4
|
+
type ResultInfo,
|
|
5
|
+
tokenOf,
|
|
6
|
+
} from './creds-cloudflare-api';
|
|
7
|
+
|
|
8
|
+
export const decodeCloudflareTokenPage = (
|
|
9
|
+
result: unknown,
|
|
10
|
+
): CfResult<ReadonlyArray<CloudflareToken>> => {
|
|
11
|
+
if (!Array.isArray(result)) {
|
|
12
|
+
return { ok: false, problem: 'token list returned a non-array result' };
|
|
13
|
+
}
|
|
14
|
+
const tokens = result
|
|
15
|
+
.map(tokenOf)
|
|
16
|
+
.filter((entry): entry is CloudflareToken => entry !== null);
|
|
17
|
+
return tokens.length === result.length
|
|
18
|
+
? { ok: true, value: tokens }
|
|
19
|
+
: { ok: false, problem: 'token list returned a malformed token' };
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const cloudflarePaginationProblem = (input: {
|
|
23
|
+
readonly page: number;
|
|
24
|
+
readonly resultLength: number;
|
|
25
|
+
readonly resultInfo: ResultInfo;
|
|
26
|
+
readonly accumulated: number;
|
|
27
|
+
readonly expectedTotal: number;
|
|
28
|
+
readonly duplicate: boolean;
|
|
29
|
+
}): string | null => {
|
|
30
|
+
if (
|
|
31
|
+
input.resultInfo.page !== input.page ||
|
|
32
|
+
input.resultInfo.count !== input.resultLength
|
|
33
|
+
) {
|
|
34
|
+
return `token list returned inconsistent pagination metadata for page ${input.page}`;
|
|
35
|
+
}
|
|
36
|
+
if (
|
|
37
|
+
input.resultInfo.totalCount !== input.expectedTotal ||
|
|
38
|
+
input.accumulated + input.resultLength > input.expectedTotal
|
|
39
|
+
) {
|
|
40
|
+
return `token list returned an inconsistent total_count for page ${input.page}`;
|
|
41
|
+
}
|
|
42
|
+
if (
|
|
43
|
+
input.duplicate ||
|
|
44
|
+
(input.resultLength === 0 && input.accumulated < input.expectedTotal)
|
|
45
|
+
) {
|
|
46
|
+
return `token list made no unique progress on page ${input.page}`;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
};
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Cloudflare account-owned API token operations for `standards creds`: the
|
|
2
|
+
// broker bootstrap token (permission "Account API Tokens Write") verifies
|
|
3
|
+
// itself, lists and mints scoped tokens, and revokes them. Token values
|
|
4
|
+
// returned by create flow only into SOPS writers,
|
|
5
|
+
// never stdout.
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
type CfResult,
|
|
9
|
+
type CloudflareToken,
|
|
10
|
+
cfRequest,
|
|
11
|
+
type PermissionGroup,
|
|
12
|
+
type TokenPolicy,
|
|
13
|
+
} from './creds-cloudflare-api';
|
|
14
|
+
import {
|
|
15
|
+
encodeTokenCondition,
|
|
16
|
+
type TokenCondition,
|
|
17
|
+
} from './creds-cloudflare-condition';
|
|
18
|
+
import {
|
|
19
|
+
cloudflarePaginationProblem,
|
|
20
|
+
decodeCloudflareTokenPage,
|
|
21
|
+
} from './creds-cloudflare-pagination';
|
|
22
|
+
import { isRecord } from './github-settings-parse';
|
|
23
|
+
|
|
24
|
+
const PAGE_SIZE = 50;
|
|
25
|
+
|
|
26
|
+
export type VerifiedToken = {
|
|
27
|
+
readonly id: string;
|
|
28
|
+
readonly status: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const verifyAccountToken = async (
|
|
32
|
+
accountId: string,
|
|
33
|
+
token: string,
|
|
34
|
+
): Promise<CfResult<VerifiedToken>> => {
|
|
35
|
+
const response = await cfRequest(
|
|
36
|
+
token,
|
|
37
|
+
'GET',
|
|
38
|
+
`/accounts/${accountId}/tokens/verify`,
|
|
39
|
+
);
|
|
40
|
+
if (!response.ok) {
|
|
41
|
+
return response;
|
|
42
|
+
}
|
|
43
|
+
const result = isRecord(response.value.result) ? response.value.result : {};
|
|
44
|
+
if (typeof result.id !== 'string' || result.id.length === 0) {
|
|
45
|
+
return {
|
|
46
|
+
ok: false,
|
|
47
|
+
problem: 'token verification returned no valid token ID',
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
ok: true,
|
|
52
|
+
value: {
|
|
53
|
+
id: result.id,
|
|
54
|
+
status: typeof result.status === 'string' ? result.status : 'unknown',
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const listAccountTokens = async (
|
|
60
|
+
accountId: string,
|
|
61
|
+
token: string,
|
|
62
|
+
): Promise<CfResult<ReadonlyArray<CloudflareToken>>> => {
|
|
63
|
+
const tokens: Array<CloudflareToken> = [];
|
|
64
|
+
const tokenIds = new Set<string>();
|
|
65
|
+
let expectedTotal: number | null = null;
|
|
66
|
+
for (let page = 1; ; page += 1) {
|
|
67
|
+
// biome-ignore lint/performance/noAwaitInLoops: pages are sequential by definition; the next request depends on the previous page count.
|
|
68
|
+
const response = await cfRequest(
|
|
69
|
+
token,
|
|
70
|
+
'GET',
|
|
71
|
+
`/accounts/${accountId}/tokens?include_expired=true&page=${page}&per_page=${PAGE_SIZE}`,
|
|
72
|
+
);
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
return response;
|
|
75
|
+
}
|
|
76
|
+
const { result, resultInfo } = response.value;
|
|
77
|
+
const decoded = decodeCloudflareTokenPage(result);
|
|
78
|
+
if (!decoded.ok) {
|
|
79
|
+
return decoded;
|
|
80
|
+
}
|
|
81
|
+
const pageTokens = decoded.value;
|
|
82
|
+
if (resultInfo === null) {
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
problem:
|
|
86
|
+
'token list returned invalid pagination metadata (expected page, per_page, count, and total_count)',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
expectedTotal ??= resultInfo.totalCount;
|
|
90
|
+
const problem = cloudflarePaginationProblem({
|
|
91
|
+
page,
|
|
92
|
+
resultLength: pageTokens.length,
|
|
93
|
+
resultInfo,
|
|
94
|
+
accumulated: tokens.length,
|
|
95
|
+
expectedTotal,
|
|
96
|
+
duplicate:
|
|
97
|
+
new Set(pageTokens.map((entry) => entry.id)).size !==
|
|
98
|
+
pageTokens.length ||
|
|
99
|
+
pageTokens.some((entry) => tokenIds.has(entry.id)),
|
|
100
|
+
});
|
|
101
|
+
if (problem !== null) {
|
|
102
|
+
return { ok: false, problem };
|
|
103
|
+
}
|
|
104
|
+
for (const pageToken of pageTokens) {
|
|
105
|
+
tokenIds.add(pageToken.id);
|
|
106
|
+
}
|
|
107
|
+
tokens.push(...pageTokens);
|
|
108
|
+
if (tokens.length === expectedTotal) {
|
|
109
|
+
return { ok: true, value: tokens };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export const listPermissionGroups = async (
|
|
115
|
+
accountId: string,
|
|
116
|
+
token: string,
|
|
117
|
+
): Promise<CfResult<ReadonlyArray<PermissionGroup>>> => {
|
|
118
|
+
const response = await cfRequest(
|
|
119
|
+
token,
|
|
120
|
+
'GET',
|
|
121
|
+
`/accounts/${accountId}/tokens/permission_groups?per_page=1000`,
|
|
122
|
+
);
|
|
123
|
+
if (!response.ok) {
|
|
124
|
+
return response;
|
|
125
|
+
}
|
|
126
|
+
const groups = Array.isArray(response.value.result)
|
|
127
|
+
? response.value.result
|
|
128
|
+
.filter(isRecord)
|
|
129
|
+
.filter(
|
|
130
|
+
(group): group is Record<string, unknown> & PermissionGroup =>
|
|
131
|
+
typeof group.id === 'string' &&
|
|
132
|
+
typeof group.name === 'string' &&
|
|
133
|
+
Array.isArray(group.scopes) &&
|
|
134
|
+
group.scopes.every((scope) => typeof scope === 'string'),
|
|
135
|
+
)
|
|
136
|
+
.map((group) => ({
|
|
137
|
+
id: group.id,
|
|
138
|
+
name: group.name,
|
|
139
|
+
scopes: group.scopes,
|
|
140
|
+
}))
|
|
141
|
+
: [];
|
|
142
|
+
return { ok: true, value: groups };
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export type CreatedToken = { readonly id: string; readonly value: string };
|
|
146
|
+
|
|
147
|
+
export const createAccountToken = async (
|
|
148
|
+
accountId: string,
|
|
149
|
+
token: string,
|
|
150
|
+
request: {
|
|
151
|
+
readonly name: string;
|
|
152
|
+
readonly policies: ReadonlyArray<TokenPolicy>;
|
|
153
|
+
readonly expiresOn: string | null;
|
|
154
|
+
readonly condition: TokenCondition | null;
|
|
155
|
+
},
|
|
156
|
+
): Promise<CfResult<CreatedToken>> => {
|
|
157
|
+
const response = await cfRequest(
|
|
158
|
+
token,
|
|
159
|
+
'POST',
|
|
160
|
+
`/accounts/${accountId}/tokens`,
|
|
161
|
+
{
|
|
162
|
+
name: request.name,
|
|
163
|
+
policies: request.policies,
|
|
164
|
+
...(request.expiresOn === null ? {} : { expires_on: request.expiresOn }),
|
|
165
|
+
...(request.condition === null
|
|
166
|
+
? {}
|
|
167
|
+
: { condition: encodeTokenCondition(request.condition) }),
|
|
168
|
+
},
|
|
169
|
+
);
|
|
170
|
+
if (!response.ok) {
|
|
171
|
+
return response;
|
|
172
|
+
}
|
|
173
|
+
const { result } = response.value;
|
|
174
|
+
if (
|
|
175
|
+
!(
|
|
176
|
+
isRecord(result) &&
|
|
177
|
+
typeof result.id === 'string' &&
|
|
178
|
+
typeof result.value === 'string'
|
|
179
|
+
)
|
|
180
|
+
) {
|
|
181
|
+
return { ok: false, problem: 'token creation returned no id and value' };
|
|
182
|
+
}
|
|
183
|
+
return { ok: true, value: { id: result.id, value: result.value } };
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
export const deleteAccountToken = async (
|
|
187
|
+
accountId: string,
|
|
188
|
+
token: string,
|
|
189
|
+
tokenId: string,
|
|
190
|
+
): Promise<CfResult<null>> => {
|
|
191
|
+
const response = await cfRequest(
|
|
192
|
+
token,
|
|
193
|
+
'DELETE',
|
|
194
|
+
`/accounts/${accountId}/tokens/${tokenId}`,
|
|
195
|
+
);
|
|
196
|
+
return response.ok ? { ok: true, value: null } : response;
|
|
197
|
+
};
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
import { runCredsAddCloudflare } from './creds-add';
|
|
4
|
+
import { runCredsAddGithub } from './creds-add-github';
|
|
5
|
+
import { listPermissionGroups } from './creds-cloudflare';
|
|
6
|
+
import { runCredsLoginCloudflare } from './creds-login-cloudflare';
|
|
7
|
+
import { runCredsLoginGithub } from './creds-login-github';
|
|
8
|
+
import { BROKER_IDENTITY_NAME } from './creds-naming';
|
|
9
|
+
import { runCredsPlan } from './creds-plan-run';
|
|
10
|
+
import {
|
|
11
|
+
inspectBrokerFileMode,
|
|
12
|
+
readBrokerStore,
|
|
13
|
+
resolveBrokerPath,
|
|
14
|
+
} from './creds-store';
|
|
15
|
+
|
|
16
|
+
const CREDS_USAGE = `Usage: standards creds <command> [options]
|
|
17
|
+
|
|
18
|
+
Commands:
|
|
19
|
+
login github Create the broker GitHub App via the manifest flow (one click) and store its credentials
|
|
20
|
+
login cloudflare Store a Cloudflare account's bootstrap token (one guided paste per account)
|
|
21
|
+
add cloudflare Mint a scoped, expiring account token and write it into a SOPS target
|
|
22
|
+
add github Write the broker App's credentials into a SOPS target for runtime token minting
|
|
23
|
+
plan Show revocations and rotations reconciling SOPS keys against brokered tokens
|
|
24
|
+
apply Execute the plan: revoke orphaned tokens, roll expiring ones into SOPS
|
|
25
|
+
permissions List Cloudflare permission group names for --permissions
|
|
26
|
+
status Show the broker store location and configured providers
|
|
27
|
+
|
|
28
|
+
Options:
|
|
29
|
+
--dir <path> Repository to operate on (default: current directory)
|
|
30
|
+
--dest <target>:<key> SOPS destination, e.g. ci:ci.cloudflare_dns_token
|
|
31
|
+
--permissions <list> Comma-separated Cloudflare permission group names
|
|
32
|
+
--account <id> Cloudflare account when more than one is configured
|
|
33
|
+
--ttl-days <n> Token lifetime in days (default: 90)
|
|
34
|
+
--org <org> Create the GitHub App under an organization
|
|
35
|
+
--name <name> GitHub App name (default: ${BROKER_IDENTITY_NAME})
|
|
36
|
+
|
|
37
|
+
Secret values are written directly into SOPS-encrypted targets and never printed.`;
|
|
38
|
+
const DAY_MS = 86_400_000;
|
|
39
|
+
const MAX_PROVIDER_EXPIRATION_MS = Date.parse('9999-12-31T23:59:59.999Z');
|
|
40
|
+
const POSITIVE_INTEGER = /^[1-9][0-9]*$/u;
|
|
41
|
+
|
|
42
|
+
type CredsFlags = {
|
|
43
|
+
dir: string;
|
|
44
|
+
dest: string | undefined;
|
|
45
|
+
permissions: string | undefined;
|
|
46
|
+
account: string | undefined;
|
|
47
|
+
ttlDays: number | undefined;
|
|
48
|
+
org: string | undefined;
|
|
49
|
+
name: string | undefined;
|
|
50
|
+
readonly words: Array<string>;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const flagValue = (argv: ReadonlyArray<string>, index: number): string => {
|
|
54
|
+
const value = argv[index + 1];
|
|
55
|
+
if (value === undefined || value.startsWith('--')) {
|
|
56
|
+
throw new Error(`${argv[index]} requires a value`);
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const parseTtlDays = (raw: string): number => {
|
|
62
|
+
const ttlDays = Number(raw);
|
|
63
|
+
if (
|
|
64
|
+
!(POSITIVE_INTEGER.test(raw) && Number.isSafeInteger(ttlDays)) ||
|
|
65
|
+
Date.now() + ttlDays * DAY_MS > MAX_PROVIDER_EXPIRATION_MS
|
|
66
|
+
) {
|
|
67
|
+
throw new Error('--ttl-days must be a provider-safe positive integer');
|
|
68
|
+
}
|
|
69
|
+
return ttlDays;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const parseCredsArgs = (argv: ReadonlyArray<string>): CredsFlags => {
|
|
73
|
+
const flags: CredsFlags = {
|
|
74
|
+
dir: process.cwd(),
|
|
75
|
+
dest: undefined,
|
|
76
|
+
permissions: undefined,
|
|
77
|
+
account: undefined,
|
|
78
|
+
ttlDays: undefined,
|
|
79
|
+
org: undefined,
|
|
80
|
+
name: undefined,
|
|
81
|
+
words: [],
|
|
82
|
+
};
|
|
83
|
+
const setters: Readonly<Record<string, (value: string) => void>> = {
|
|
84
|
+
'--dir': (value) => {
|
|
85
|
+
flags.dir = value;
|
|
86
|
+
},
|
|
87
|
+
'--dest': (value) => {
|
|
88
|
+
flags.dest = value;
|
|
89
|
+
},
|
|
90
|
+
'--permissions': (value) => {
|
|
91
|
+
flags.permissions = value;
|
|
92
|
+
},
|
|
93
|
+
'--account': (value) => {
|
|
94
|
+
flags.account = value;
|
|
95
|
+
},
|
|
96
|
+
'--ttl-days': (value) => {
|
|
97
|
+
flags.ttlDays = parseTtlDays(value);
|
|
98
|
+
},
|
|
99
|
+
'--org': (value) => {
|
|
100
|
+
flags.org = value;
|
|
101
|
+
},
|
|
102
|
+
'--name': (value) => {
|
|
103
|
+
flags.name = value;
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
107
|
+
const arg = argv[index] ?? '';
|
|
108
|
+
const setter = setters[arg];
|
|
109
|
+
if (setter !== undefined) {
|
|
110
|
+
setter(flagValue(argv, index));
|
|
111
|
+
index += 1;
|
|
112
|
+
} else if (arg.startsWith('--')) {
|
|
113
|
+
throw new Error(`Unknown creds option: ${arg}`);
|
|
114
|
+
} else {
|
|
115
|
+
flags.words.push(arg);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
flags.dir = resolve(flags.dir);
|
|
119
|
+
return flags;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const runCredsStatus = async (): Promise<boolean> => {
|
|
123
|
+
const path = resolveBrokerPath();
|
|
124
|
+
const store = await readBrokerStore(path);
|
|
125
|
+
console.log(`broker store: ${path}`);
|
|
126
|
+
const mode = inspectBrokerFileMode(path);
|
|
127
|
+
if (!mode.exists) {
|
|
128
|
+
console.log(' (not created yet)');
|
|
129
|
+
} else if (mode.problem !== null) {
|
|
130
|
+
console.log(` WARNING: ${mode.problem}`);
|
|
131
|
+
}
|
|
132
|
+
console.log(
|
|
133
|
+
store.github === null
|
|
134
|
+
? 'github: not configured (`standards creds login github`)'
|
|
135
|
+
: `github: App ${store.github.slug} (id ${store.github.appId}) — ${store.github.htmlUrl}`,
|
|
136
|
+
);
|
|
137
|
+
console.log(
|
|
138
|
+
store.cloudflare.length === 0
|
|
139
|
+
? 'cloudflare: not configured (`standards creds login cloudflare`)'
|
|
140
|
+
: `cloudflare: ${store.cloudflare.map((entry) => entry.accountId).join(', ')}`,
|
|
141
|
+
);
|
|
142
|
+
return true;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const runCredsPermissions = async (
|
|
146
|
+
account: string | undefined,
|
|
147
|
+
): Promise<boolean> => {
|
|
148
|
+
const store = await readBrokerStore(resolveBrokerPath());
|
|
149
|
+
const selected =
|
|
150
|
+
account === undefined && store.cloudflare.length === 1
|
|
151
|
+
? store.cloudflare[0]
|
|
152
|
+
: store.cloudflare.find((entry) => entry.accountId === account);
|
|
153
|
+
if (selected === undefined) {
|
|
154
|
+
console.error(
|
|
155
|
+
'standards creds: configure an account with `standards creds login cloudflare` (pass --account when several are configured)',
|
|
156
|
+
);
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
const groups = await listPermissionGroups(selected.accountId, selected.token);
|
|
160
|
+
if (!groups.ok) {
|
|
161
|
+
console.error(`standards creds: ${groups.problem}`);
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
for (const group of [...groups.value].sort((a, b) =>
|
|
165
|
+
a.name.localeCompare(b.name),
|
|
166
|
+
)) {
|
|
167
|
+
console.log(group.name);
|
|
168
|
+
}
|
|
169
|
+
return true;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export const runCredsCommand = (
|
|
173
|
+
argv: ReadonlyArray<string>,
|
|
174
|
+
): Promise<boolean> => {
|
|
175
|
+
const flags = parseCredsArgs(argv);
|
|
176
|
+
const route = flags.words.join(' ');
|
|
177
|
+
if (route === '' || route === 'help') {
|
|
178
|
+
console.log(CREDS_USAGE);
|
|
179
|
+
return Promise.resolve(route === 'help');
|
|
180
|
+
}
|
|
181
|
+
const handlers: Readonly<Record<string, () => Promise<boolean>>> = {
|
|
182
|
+
'login github': () =>
|
|
183
|
+
runCredsLoginGithub({ name: flags.name, org: flags.org }),
|
|
184
|
+
'login cloudflare': () =>
|
|
185
|
+
runCredsLoginCloudflare({ account: flags.account }),
|
|
186
|
+
'add cloudflare': () => runCredsAddCloudflare(flags.dir, flags),
|
|
187
|
+
'add github': () => runCredsAddGithub(flags.dir, flags),
|
|
188
|
+
plan: () => runCredsPlan(flags.dir, false),
|
|
189
|
+
apply: () => runCredsPlan(flags.dir, true),
|
|
190
|
+
permissions: () => runCredsPermissions(flags.account),
|
|
191
|
+
status: () => runCredsStatus(),
|
|
192
|
+
};
|
|
193
|
+
const handler = handlers[route];
|
|
194
|
+
if (handler === undefined) {
|
|
195
|
+
console.error(`standards creds: unknown command: ${route}\n`);
|
|
196
|
+
console.error(CREDS_USAGE);
|
|
197
|
+
return Promise.resolve(false);
|
|
198
|
+
}
|
|
199
|
+
return handler();
|
|
200
|
+
};
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// Shared destination validation and repository/SOPS target resolution for
|
|
2
|
+
// `standards creds add`. Untrusted paths are rejected before any SOPS or
|
|
3
|
+
// provider operation can run.
|
|
4
|
+
|
|
5
|
+
import { lstatSync, readdirSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { isContainedSopsPath, parseSopsKeyPath } from './creds-sops-structure';
|
|
8
|
+
import {
|
|
9
|
+
type BrokerStore,
|
|
10
|
+
type CloudflareBrokerAccount,
|
|
11
|
+
readBrokerStore,
|
|
12
|
+
resolveBrokerPath,
|
|
13
|
+
} from './creds-store';
|
|
14
|
+
import { resolveGithubRepo } from './github-api';
|
|
15
|
+
import { isRecord } from './github-settings-parse';
|
|
16
|
+
|
|
17
|
+
const SAFE_TARGET = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9_-])?$/u;
|
|
18
|
+
|
|
19
|
+
export type CredsDestination = {
|
|
20
|
+
readonly target: string;
|
|
21
|
+
readonly key: string;
|
|
22
|
+
};
|
|
23
|
+
export type SecretsTarget = { readonly target: string; readonly rel: string };
|
|
24
|
+
export type ResolvedContext = {
|
|
25
|
+
readonly repo: string;
|
|
26
|
+
readonly rel: string;
|
|
27
|
+
readonly dest: CredsDestination;
|
|
28
|
+
readonly store: BrokerStore;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const parseDestination = (raw: string): CredsDestination | null => {
|
|
32
|
+
const separator = raw.indexOf(':');
|
|
33
|
+
const target = raw.slice(0, separator);
|
|
34
|
+
const key = raw.slice(separator + 1);
|
|
35
|
+
return separator > 0 &&
|
|
36
|
+
separator < raw.length - 1 &&
|
|
37
|
+
SAFE_TARGET.test(target) &&
|
|
38
|
+
parseSopsKeyPath(key) !== null
|
|
39
|
+
? { target, key }
|
|
40
|
+
: null;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const assertWritableSopsPath = (
|
|
44
|
+
root: Readonly<Record<string, unknown>>,
|
|
45
|
+
path: ReadonlyArray<string>,
|
|
46
|
+
): void => {
|
|
47
|
+
let node: unknown = root;
|
|
48
|
+
for (const [index, segment] of path.entries()) {
|
|
49
|
+
if (!isRecord(node)) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`SOPS key path is blocked by a scalar: ${path.join('.')}`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
const next = node[segment];
|
|
55
|
+
if (next === undefined) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (index === path.length - 1 && isRecord(next)) {
|
|
59
|
+
throw new Error(`SOPS key path names a mapping: ${path.join('.')}`);
|
|
60
|
+
}
|
|
61
|
+
node = next;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const isYamlSecrets = (name: string): boolean =>
|
|
66
|
+
name.endsWith('.yaml') && !name.endsWith('.example.yaml');
|
|
67
|
+
const listDir = (consumer: string, rel: string): ReadonlyArray<string> => {
|
|
68
|
+
if (!isContainedSopsPath(consumer, rel, 'directory')) {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
return readdirSync(join(consumer, rel));
|
|
73
|
+
} catch {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const isHostTargetCandidate = (consumer: string, name: string): boolean => {
|
|
78
|
+
const rel = `infra/hosts/${name}`;
|
|
79
|
+
if (isContainedSopsPath(consumer, rel, 'directory')) {
|
|
80
|
+
return listDir(consumer, rel).includes('secrets.yaml');
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
return lstatSync(join(consumer, rel)).isSymbolicLink();
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export const listSecretsTargets = (
|
|
90
|
+
consumer: string,
|
|
91
|
+
): ReadonlyArray<SecretsTarget> => {
|
|
92
|
+
const flat = listDir(consumer, 'secrets')
|
|
93
|
+
.filter(isYamlSecrets)
|
|
94
|
+
.map((name) => ({
|
|
95
|
+
target: name.slice(0, -'.yaml'.length),
|
|
96
|
+
rel: `secrets/${name}`,
|
|
97
|
+
}))
|
|
98
|
+
.filter(({ target }) => SAFE_TARGET.test(target));
|
|
99
|
+
const hosts = listDir(consumer, 'infra/hosts')
|
|
100
|
+
.filter(
|
|
101
|
+
(name) => SAFE_TARGET.test(name) && isHostTargetCandidate(consumer, name),
|
|
102
|
+
)
|
|
103
|
+
.map((name) => ({
|
|
104
|
+
target: name,
|
|
105
|
+
rel: `infra/hosts/${name}/secrets.yaml`,
|
|
106
|
+
}));
|
|
107
|
+
return [...flat, ...hosts];
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export const resolveTargetRel = (
|
|
111
|
+
consumer: string,
|
|
112
|
+
target: string,
|
|
113
|
+
): string | null => {
|
|
114
|
+
if (!SAFE_TARGET.test(target)) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
const host = `infra/hosts/${target}/secrets.yaml`;
|
|
118
|
+
if (isContainedSopsPath(consumer, host, 'file')) {
|
|
119
|
+
return host;
|
|
120
|
+
}
|
|
121
|
+
const flat = `secrets/${target}.yaml`;
|
|
122
|
+
return isContainedSopsPath(consumer, flat, 'file') ? flat : null;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export const resolveContext = async (
|
|
126
|
+
consumer: string,
|
|
127
|
+
destRaw: string | undefined,
|
|
128
|
+
): Promise<ResolvedContext | null> => {
|
|
129
|
+
if (destRaw === undefined) {
|
|
130
|
+
console.error(
|
|
131
|
+
'standards creds: --dest <target>:<dotted.key> is required (e.g. --dest ci:ci.cloudflare_dns_token)',
|
|
132
|
+
);
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
const dest = parseDestination(destRaw);
|
|
136
|
+
if (dest === null) {
|
|
137
|
+
console.error(`standards creds: invalid --dest value: ${destRaw}`);
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
const repo = resolveGithubRepo(consumer);
|
|
141
|
+
if (repo === null) {
|
|
142
|
+
console.error(
|
|
143
|
+
'standards creds: cannot resolve the GitHub repository from the origin remote',
|
|
144
|
+
);
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
const rel = resolveTargetRel(consumer, dest.target);
|
|
148
|
+
if (rel === null) {
|
|
149
|
+
console.error(
|
|
150
|
+
`standards creds: secrets target "${dest.target}" not found; create it with \`just secrets edit ${dest.target}\` first`,
|
|
151
|
+
);
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
return { repo, rel, dest, store: await readBrokerStore(resolveBrokerPath()) };
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export const selectAccount = (
|
|
158
|
+
store: BrokerStore,
|
|
159
|
+
accountId: string | undefined,
|
|
160
|
+
): CloudflareBrokerAccount | null => {
|
|
161
|
+
if (store.cloudflare.length === 0) {
|
|
162
|
+
console.error(
|
|
163
|
+
'standards creds: no Cloudflare accounts configured; run `standards creds login cloudflare`',
|
|
164
|
+
);
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
if (accountId === undefined) {
|
|
168
|
+
if (store.cloudflare.length === 1) {
|
|
169
|
+
return store.cloudflare[0] ?? null;
|
|
170
|
+
}
|
|
171
|
+
console.error(
|
|
172
|
+
`standards creds: multiple Cloudflare accounts configured; pass --account (${store.cloudflare.map((entry) => entry.accountId).join(', ')})`,
|
|
173
|
+
);
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
const account = store.cloudflare.find(
|
|
177
|
+
(entry) => entry.accountId === accountId,
|
|
178
|
+
);
|
|
179
|
+
if (account === undefined) {
|
|
180
|
+
console.error(`standards creds: account ${accountId} is not configured`);
|
|
181
|
+
}
|
|
182
|
+
return account ?? null;
|
|
183
|
+
};
|